From 6c71f87589d9e47124e77359054931afae08dd56 Mon Sep 17 00:00:00 2001
From: ZheFox <77232781+zhefox@users.noreply.github.com>
Date: Tue, 1 Sep 2026 15:38:00 +0800
Subject: [PATCH 01/99] fix(frontend): align Antigravity quota summaries
---
.../__tests__/PoolKeyDisplayPanels.spec.ts | 4 +--
.../components/AntigravityQuotaDialog.vue | 14 +++++---
.../components/ProviderDetailDrawer.vue | 25 ++++-----------
.../components/ProviderQuotaProgressRow.vue | 4 ++-
.../__tests__/AntigravityQuotaDialog.spec.ts | 32 +++++++++++--------
.../__tests__/antigravityQuotaSummary.spec.ts | 2 +-
.../providers/utils/antigravityQuota.ts | 2 +-
7 files changed, 42 insertions(+), 41 deletions(-)
diff --git a/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts b/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts
index 8ac3b3dcb..e2246ce07 100644
--- a/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts
+++ b/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts
@@ -104,7 +104,7 @@ describe('pool key display panels', () => {
meterClass: 'text-emerald-600',
},
{
- label: 'Claude额度',
+ label: 'Claude & ChatGPT',
remainingPercent: 100,
resetText: '1h 后重置',
meterText: '100%',
@@ -119,7 +119,7 @@ describe('pool key display panels', () => {
expect(root.querySelector('[data-testid="pool-quota-rows"]')?.className).toContain('space-y-2')
expect(Array.from(root.querySelectorAll('[data-testid="pool-quota-period-label"]')).map(node => node.textContent)).toEqual([
'Gemini额度',
- 'Claude额度',
+ 'Claude & ChatGPT',
])
expect(Array.from(root.querySelectorAll('[data-testid="pool-quota-meter-text"]')).map(node => node.textContent)).toEqual(['90.6%–100%', '100%'])
expect(root.querySelectorAll('[data-testid="pool-quota-progress-track"]')).toHaveLength(2)
diff --git a/frontend/src/features/providers/components/AntigravityQuotaDialog.vue b/frontend/src/features/providers/components/AntigravityQuotaDialog.vue
index b180d67d8..2fec683f7 100644
--- a/frontend/src/features/providers/components/AntigravityQuotaDialog.vue
+++ b/frontend/src/features/providers/components/AntigravityQuotaDialog.vue
@@ -8,7 +8,7 @@
@update:model-value="$emit('update:open', $event)"
>
@@ -32,7 +32,7 @@
{{ item.label }}
- {{ item.remainingPercent.toFixed(1) }}%
+ {{ item.detail || `${item.remainingPercent.toFixed(1)}%` }}
@@ -122,6 +122,7 @@ import {
compareAntigravityQuotaItems,
dedupeAntigravityQuotaItemsByLabel,
resolveAntigravityQuotaLabel,
+ summarizeAntigravityQuotaItems,
} from '@/features/providers/utils/antigravityQuota'
const props = defineProps<{
@@ -143,6 +144,7 @@ interface QuotaItem {
usedPercent: number
remainingPercent: number
resetSeconds: number | null
+ detail?: string
}
const { error: showError, success: showSuccess } = useToast()
@@ -236,7 +238,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin
return dedupeAntigravityQuotaItemsByLabel(items)
}
-const items = computed
(() => {
+const rawItems = computed(() => {
const snapshotItems = buildItemsFromQuotaSnapshot(props.quotaSnapshot)
if (snapshotItems.length > 0) return snapshotItems
@@ -288,6 +290,8 @@ const items = computed(() => {
return dedupeAntigravityQuotaItemsByLabel(result)
})
+const items = computed(() => summarizeAntigravityQuotaItems(rawItems.value))
+
async function handleTestModel(modelName: string) {
if (!props.providerId || testingModel.value) return
diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue
index faacfd3fb..681484934 100644
--- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue
+++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue
@@ -376,12 +376,13 @@
/>
-
@@ -989,6 +982,7 @@ import {
compareAntigravityQuotaItems,
dedupeAntigravityQuotaItemsByLabel,
resolveAntigravityQuotaLabel,
+ summarizeAntigravityQuotaItems,
} from '@/features/providers/utils/antigravityQuota'
import {
deleteEndpointKey,
@@ -3374,6 +3368,7 @@ interface AntigravityQuotaItem {
usedPercent: number
remainingPercent: number
resetSeconds: number | null
+ detail?: string
}
interface GeminiCliQuotaItem {
@@ -3579,20 +3574,14 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ
return dedupeAntigravityQuotaItemsByLabel(items)
}
-const ANTIGRAVITY_QUOTA_PREVIEW_LIMIT = 6
-
function getAntigravityQuotaItemsForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key)
if (snapshotItems.length > 0) return snapshotItems
return getAntigravityQuotaItems(key.upstream_metadata)
}
-function getAntigravityQuotaPreviewForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
- return getAntigravityQuotaItemsForKey(key).slice(0, ANTIGRAVITY_QUOTA_PREVIEW_LIMIT)
-}
-
-function getAntigravityQuotaHiddenCountForKey(key: EndpointAPIKey): number {
- return Math.max(getAntigravityQuotaItemsForKey(key).length - ANTIGRAVITY_QUOTA_PREVIEW_LIMIT, 0)
+function getAntigravityQuotaSummaryForKey(key: EndpointAPIKey): AntigravityQuotaItem[] {
+ return summarizeAntigravityQuotaItems(getAntigravityQuotaItemsForKey(key))
}
function getResetCountdownText(
diff --git a/frontend/src/features/providers/components/ProviderQuotaProgressRow.vue b/frontend/src/features/providers/components/ProviderQuotaProgressRow.vue
index 45b72097e..45d7db090 100644
--- a/frontend/src/features/providers/components/ProviderQuotaProgressRow.vue
+++ b/frontend/src/features/providers/components/ProviderQuotaProgressRow.vue
@@ -11,7 +11,7 @@
:class="meterClass"
data-testid="provider-quota-progress-meter"
>
- {{ normalizedRemainingPercent.toFixed(1) }}%
+ {{ meterText || `${normalizedRemainingPercent.toFixed(1)}%` }}
@@ -47,6 +47,7 @@ const props = withDefaults(defineProps<{
barClass?: string
footerClass?: string
resetText?: string | null
+ meterText?: string | null
}>(), {
usedPercent: null,
remainingPercent: null,
@@ -55,6 +56,7 @@ const props = withDefaults(defineProps<{
barClass: '',
footerClass: '',
resetText: null,
+ meterText: null,
})
function normalizePercent(value: number | null | undefined): number | null {
diff --git a/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts b/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts
index 2ad4a4616..8a7bda80b 100644
--- a/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts
+++ b/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts
@@ -102,7 +102,7 @@ function mount(metadata: UpstreamMetadata) {
}
describe('AntigravityQuotaDialog', () => {
- it('renders opaque quota identifiers with concise visible labels', () => {
+ it('hides quota buckets outside the shared pool summary families', () => {
const rawIdentifier = 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb'
const { root, unmount } = mount({
antigravity: {
@@ -116,14 +116,15 @@ describe('AntigravityQuotaDialog', () => {
},
})
- expect(root.textContent).toContain('Key-1')
+ expect(root.textContent).toContain('暂无配额数据')
+ expect(root.textContent).not.toContain('Key-1')
expect(root.textContent).not.toContain(rawIdentifier)
- expect(root.textContent).toContain('25.0%')
+ expect(root.textContent).not.toContain('25.0%')
unmount()
})
- it('orders important Gemini and Claude quota rows before low-priority rows', () => {
+ it('renders only the shared Gemini and Claude ChatGPT family summaries', () => {
const { root, unmount } = mount({
antigravity: {
quota_by_model: {
@@ -164,16 +165,20 @@ describe('AntigravityQuotaDialog', () => {
expect(text).not.toContain('gemini-pro-agent')
expect(text).not.toContain('claude-opus-4-6-thinking')
- expect(text.indexOf('Claude Opus 4.6 (Thinking)')).toBeLessThan(text.indexOf('Gemini 3.1 Pro (High)'))
- expect(text.indexOf('Gemini 3.1 Pro (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (High)'))
- expect(text.indexOf('Gemini 3.5 Flash (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (Medium)'))
- expect(text.indexOf('Gemini 3.5 Flash (Medium)')).toBeLessThan(text.indexOf('Tab Flash Lite Preview'))
- expect(text.indexOf('Tab Flash Lite Preview')).toBeLessThan(text.indexOf('chat_20706'))
+ expect(text).toContain('Gemini额度')
+ expect(text).toContain('80%–95%')
+ expect(text).toContain('Claude & ChatGPT')
+ expect(text).toContain('100%')
+ expect(text).not.toContain('Gemini 3.1 Pro (High)')
+ expect(text).not.toContain('Gemini 3.5 Flash (High)')
+ expect(text).not.toContain('Gemini 3.5 Flash (Medium)')
+ expect(text).not.toContain('Tab Flash Lite Preview')
+ expect(text).not.toContain('chat_20706')
unmount()
})
- it('renders one row for duplicate quota labels and keeps the preferred active bucket', () => {
+ it('collapses duplicate model buckets into one family range', () => {
const { root, unmount } = mount({
antigravity: {
quota_by_model: {
@@ -197,10 +202,11 @@ describe('AntigravityQuotaDialog', () => {
})
const text = root.textContent || ''
- expect(text.match(/Gemini 3\.1 Pro \(High\)/g)).toHaveLength(1)
- expect(text).toContain('40.0%')
+ expect(text.match(/Gemini额度/g)).toHaveLength(1)
+ expect(text).toContain('40%–90%')
expect(text).not.toContain('95.0%')
- expect(text.indexOf('Gemini 3.1 Pro (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (High)'))
+ expect(text).not.toContain('Gemini 3.1 Pro (High)')
+ expect(text).not.toContain('Gemini 3.5 Flash (High)')
unmount()
})
diff --git a/frontend/src/features/providers/utils/__tests__/antigravityQuotaSummary.spec.ts b/frontend/src/features/providers/utils/__tests__/antigravityQuotaSummary.spec.ts
index 877978c76..dfb91417d 100644
--- a/frontend/src/features/providers/utils/__tests__/antigravityQuotaSummary.spec.ts
+++ b/frontend/src/features/providers/utils/__tests__/antigravityQuotaSummary.spec.ts
@@ -16,7 +16,7 @@ describe('summarizeAntigravityQuotaItems', () => {
expect(items.map(item => [item.label, item.remainingPercent, item.detail])).toEqual([
['Gemini额度', 90.6, '90.6%–95%'],
- ['Claude额度', 82, '82%–100%'],
+ ['Claude & ChatGPT', 82, '82%–100%'],
])
expect(items.map(item => item.resetSeconds)).toEqual([180, 120])
expect(items[1]?.model).toBe('claude-sonnet-4-6')
diff --git a/frontend/src/features/providers/utils/antigravityQuota.ts b/frontend/src/features/providers/utils/antigravityQuota.ts
index f884d685e..6dfc88b59 100644
--- a/frontend/src/features/providers/utils/antigravityQuota.ts
+++ b/frontend/src/features/providers/utils/antigravityQuota.ts
@@ -9,7 +9,7 @@ export interface AntigravityQuotaSortableItem {
const ANTIGRAVITY_QUOTA_GROUPS = [
{ label: 'Gemini额度', matches: (model: string) => model.startsWith('gemini-') },
{
- label: 'Claude额度',
+ label: 'Claude & ChatGPT',
matches: (model: string) => model.startsWith('claude-') || model.startsWith('gpt-'),
},
] as const
From 633363e190415943c37792946c4a63acefcf3408 Mon Sep 17 00:00:00 2001
From: ZheFox <77232781+zhefox@users.noreply.github.com>
Date: Tue, 1 Sep 2026 19:25:00 +0800
Subject: [PATCH 02/99] fix(gateway): handle pool saturation and malformed
Gemini calls
---
.../src/dispatch/pool_scheduler.rs | 22 +-
.../execution_runtime/stream/commit_policy.rs | 329 ++++++++++++++++--
.../src/execution_runtime/stream/execution.rs | 130 ++++++-
.../src/execution_runtime/sync/execution.rs | 6 +
apps/aether-gateway/src/executor/outcome.rs | 16 +
apps/aether-gateway/src/handlers/proxy/mod.rs | 102 +++++-
.../src/request_candidate_runtime.rs | 54 ++-
.../shared/stream_core/format_matrix.rs | 53 +++
8 files changed, 669 insertions(+), 43 deletions(-)
diff --git a/apps/aether-gateway/src/dispatch/pool_scheduler.rs b/apps/aether-gateway/src/dispatch/pool_scheduler.rs
index eff085bd1..1399ca43f 100644
--- a/apps/aether-gateway/src/dispatch/pool_scheduler.rs
+++ b/apps/aether-gateway/src/dispatch/pool_scheduler.rs
@@ -598,11 +598,17 @@ impl<'a> PoolKeyCursor<'a> {
return;
};
self.exhaustion_skip_recorded = true;
- record_local_runtime_candidate_skip_reason(
- self.state.app(),
- trace_id,
- self.runtime_miss_pool_exhaustion_skip_reason(),
- );
+ if self.skip_reason_counts.is_empty() {
+ record_local_runtime_candidate_skip_reason(
+ self.state.app(),
+ trace_id,
+ "pool_group_exhausted",
+ );
+ return;
+ }
+ for reason in self.skip_reason_counts.keys() {
+ record_local_runtime_candidate_skip_reason(self.state.app(), trace_id, reason);
+ }
}
fn runtime_miss_pool_exhaustion_skip_reason(&self) -> &'static str {
@@ -3245,8 +3251,12 @@ mod tests {
.take_local_execution_runtime_miss_diagnostic(trace_id)
.expect("runtime miss diagnostic should exist");
assert_eq!(diagnostic.reason, "all_candidates_skipped");
- assert_eq!(diagnostic.skipped_candidate_count, Some(1));
+ assert_eq!(diagnostic.skipped_candidate_count, Some(2));
assert_eq!(diagnostic.skip_reasons.get("pool_cooldown"), Some(&1));
+ assert_eq!(
+ diagnostic.skip_reasons.get("transport_snapshot_missing"),
+ Some(&1)
+ );
}
#[tokio::test]
diff --git a/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs b/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs
index 7645c67e2..a8048b286 100644
--- a/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs
+++ b/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs
@@ -5,6 +5,7 @@ use serde_json::Value;
use crate::execution_runtime::MAX_STREAM_PREFETCH_BYTES;
const ANTHROPIC_PRECOMMIT_MAX_WAIT: Duration = Duration::from_millis(750);
+const GEMINI_PRECOMMIT_MAX_WAIT: Duration = Duration::from_millis(750);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum StreamCommitPolicy {
@@ -14,6 +15,10 @@ pub(super) enum StreamCommitPolicy {
max_bytes: usize,
max_wait: Duration,
},
+ FirstGeminiSemanticEvent {
+ max_bytes: usize,
+ max_wait: Duration,
+ },
}
impl StreamCommitPolicy {
@@ -51,6 +56,12 @@ impl StreamCommitPolicy {
max_wait: ANTHROPIC_PRECOMMIT_MAX_WAIT,
};
}
+ if provider_api_format.eq_ignore_ascii_case("gemini:generate_content") {
+ return Self::FirstGeminiSemanticEvent {
+ max_bytes: MAX_STREAM_PREFETCH_BYTES,
+ max_wait: GEMINI_PRECOMMIT_MAX_WAIT,
+ };
+ }
return Self::ResponseHeaders;
}
@@ -78,12 +89,16 @@ impl StreamCommitPolicy {
}
pub(super) const fn requires_bounded_frame_wait(self) -> bool {
- matches!(self, Self::FirstAnthropicSemanticEvent { .. })
+ matches!(
+ self,
+ Self::FirstAnthropicSemanticEvent { .. } | Self::FirstGeminiSemanticEvent { .. }
+ )
}
pub(super) const fn max_precommit_wait(self) -> Option
{
match self {
- Self::FirstAnthropicSemanticEvent { max_wait, .. } => Some(max_wait),
+ Self::FirstAnthropicSemanticEvent { max_wait, .. }
+ | Self::FirstGeminiSemanticEvent { max_wait, .. } => Some(max_wait),
Self::ResponseHeaders | Self::FirstClassifiedBody => None,
}
}
@@ -91,6 +106,10 @@ impl StreamCommitPolicy {
pub(super) const fn is_native_anthropic(self) -> bool {
matches!(self, Self::FirstAnthropicSemanticEvent { .. })
}
+
+ pub(super) const fn is_gemini(self) -> bool {
+ matches!(self, Self::FirstGeminiSemanticEvent { .. })
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -113,6 +132,7 @@ pub(super) struct StreamCommitGate {
state: StreamCommitState,
observed_bytes: usize,
anthropic: AnthropicSsePrecommitInspector,
+ gemini: GeminiSsePrecommitInspector,
}
impl StreamCommitGate {
@@ -127,6 +147,7 @@ impl StreamCommitGate {
state,
observed_bytes: 0,
anthropic: AnthropicSsePrecommitInspector::default(),
+ gemini: GeminiSsePrecommitInspector::default(),
}
}
@@ -143,21 +164,32 @@ impl StreamCommitGate {
return StreamPrecommitObservation::Commit;
}
- let StreamCommitPolicy::FirstAnthropicSemanticEvent { max_bytes, .. } = self.policy else {
- return StreamPrecommitObservation::Pending;
+ let (max_bytes, observation) = match self.policy {
+ StreamCommitPolicy::FirstAnthropicSemanticEvent { max_bytes, .. } => {
+ (max_bytes, self.anthropic.observe(chunk, max_bytes))
+ }
+ StreamCommitPolicy::FirstGeminiSemanticEvent { max_bytes, .. } => {
+ (max_bytes, self.gemini.observe(chunk, max_bytes))
+ }
+ StreamCommitPolicy::ResponseHeaders | StreamCommitPolicy::FirstClassifiedBody => {
+ return StreamPrecommitObservation::Pending;
+ }
};
self.observed_bytes = self.observed_bytes.saturating_add(chunk.len());
- match self.anthropic.observe(chunk, max_bytes) {
- AnthropicSseObservation::Pending => {}
- AnthropicSseObservation::SemanticEvent => {
+ match observation {
+ SemanticSseObservation::Pending => {}
+ SemanticSseObservation::SemanticEvent => {
self.state = StreamCommitState::Committed;
return StreamPrecommitObservation::Commit;
}
- AnthropicSseObservation::Error(body_json) => {
+ SemanticSseObservation::Error {
+ status_code,
+ body_json,
+ } => {
self.state = StreamCommitState::Terminal;
return StreamPrecommitObservation::UpstreamError {
- status_code: anthropic_error_status_code(&body_json),
+ status_code,
body_json,
};
}
@@ -179,10 +211,10 @@ impl StreamCommitGate {
}
#[derive(Debug)]
-enum AnthropicSseObservation {
+enum SemanticSseObservation {
Pending,
SemanticEvent,
- Error(Value),
+ Error { status_code: u16, body_json: Value },
}
#[derive(Debug, Default)]
@@ -191,7 +223,7 @@ struct AnthropicSsePrecommitInspector {
}
impl AnthropicSsePrecommitInspector {
- fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> AnthropicSseObservation {
+ fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> SemanticSseObservation {
let remaining = max_bytes.saturating_sub(self.buffered.len());
let truncated = chunk.len() > remaining;
self.buffered
@@ -201,15 +233,44 @@ impl AnthropicSsePrecommitInspector {
let record = self.buffered[..record_end].to_vec();
self.buffered.drain(..record_end + separator_len);
match classify_anthropic_sse_record(&record) {
- AnthropicSseObservation::Pending => {}
+ SemanticSseObservation::Pending => {}
decision => return decision,
}
}
if truncated {
- AnthropicSseObservation::SemanticEvent
+ SemanticSseObservation::SemanticEvent
} else {
- AnthropicSseObservation::Pending
+ SemanticSseObservation::Pending
+ }
+ }
+}
+
+#[derive(Debug, Default)]
+struct GeminiSsePrecommitInspector {
+ buffered: Vec,
+}
+
+impl GeminiSsePrecommitInspector {
+ fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> SemanticSseObservation {
+ let remaining = max_bytes.saturating_sub(self.buffered.len());
+ let truncated = chunk.len() > remaining;
+ self.buffered
+ .extend_from_slice(&chunk[..chunk.len().min(remaining)]);
+
+ while let Some((record_end, separator_len)) = find_sse_record_boundary(&self.buffered) {
+ let record = self.buffered[..record_end].to_vec();
+ self.buffered.drain(..record_end + separator_len);
+ match classify_gemini_sse_record(&record) {
+ SemanticSseObservation::Pending => {}
+ decision => return decision,
+ }
+ }
+
+ if truncated {
+ SemanticSseObservation::SemanticEvent
+ } else {
+ SemanticSseObservation::Pending
}
}
}
@@ -249,9 +310,9 @@ fn next_sse_line_ending(buffer: &[u8], start: usize) -> Option<(usize, usize)> {
Some((index, ending_len))
}
-fn classify_anthropic_sse_record(record: &[u8]) -> AnthropicSseObservation {
+fn classify_anthropic_sse_record(record: &[u8]) -> SemanticSseObservation {
let Ok(record) = std::str::from_utf8(record) else {
- return AnthropicSseObservation::Pending;
+ return SemanticSseObservation::Pending;
};
let normalized_record = record.replace("\r\n", "\n").replace('\r', "\n");
let mut event_type = None;
@@ -275,15 +336,18 @@ fn classify_anthropic_sse_record(record: &[u8]) -> AnthropicSseObservation {
}
}
if data.trim().is_empty() {
- return AnthropicSseObservation::Pending;
+ return SemanticSseObservation::Pending;
}
let Ok(body_json) = serde_json::from_str::(data.trim()) else {
- return AnthropicSseObservation::Pending;
+ return SemanticSseObservation::Pending;
};
let payload_type = body_json.get("type").and_then(Value::as_str).map(str::trim);
if event_type == Some("error") || payload_type == Some("error") {
- return AnthropicSseObservation::Error(body_json);
+ return SemanticSseObservation::Error {
+ status_code: anthropic_error_status_code(&body_json),
+ body_json,
+ };
}
let semantic_type = match (event_type, payload_type) {
@@ -292,12 +356,120 @@ fn classify_anthropic_sse_record(record: &[u8]) -> AnthropicSseObservation {
_ => None,
};
if semantic_type.is_some_and(is_anthropic_semantic_event_type) {
- AnthropicSseObservation::SemanticEvent
+ SemanticSseObservation::SemanticEvent
} else {
- AnthropicSseObservation::Pending
+ SemanticSseObservation::Pending
}
}
+fn classify_gemini_sse_record(record: &[u8]) -> SemanticSseObservation {
+ let Ok(record) = std::str::from_utf8(record) else {
+ return SemanticSseObservation::Pending;
+ };
+ let data = record
+ .replace("\r\n", "\n")
+ .replace('\r', "\n")
+ .lines()
+ .filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
+ .collect::>()
+ .join("\n");
+ if data.trim().is_empty() {
+ return SemanticSseObservation::Pending;
+ }
+ if data.trim() == "[DONE]" {
+ return SemanticSseObservation::SemanticEvent;
+ }
+
+ let Ok(body_json) = serde_json::from_str::(data.trim()) else {
+ return SemanticSseObservation::Pending;
+ };
+ let response = body_json.get("response").unwrap_or(&body_json);
+ let Some(candidates) = response.get("candidates").and_then(Value::as_array) else {
+ return SemanticSseObservation::Pending;
+ };
+
+ for candidate in candidates {
+ let finish_reason = candidate
+ .get("finishReason")
+ .or_else(|| candidate.get("finish_reason"))
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty());
+ if let Some(finish_reason) = finish_reason.filter(|reason| {
+ matches!(
+ *reason,
+ "MALFORMED_FUNCTION_CALL"
+ | "UNEXPECTED_TOOL_CALL"
+ | "TOO_MANY_TOOL_CALLS"
+ | "MISSING_THOUGHT_SIGNATURE"
+ | "MALFORMED_RESPONSE"
+ )
+ }) {
+ let message = candidate
+ .get("finishMessage")
+ .or_else(|| candidate.get("finish_message"))
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned)
+ .unwrap_or_else(|| format!("Gemini stream ended with {finish_reason}"));
+ return SemanticSseObservation::Error {
+ status_code: 502,
+ body_json: serde_json::json!({
+ "error": {
+ "type": "upstream_gemini_finish_error",
+ "code": finish_reason,
+ "message": message,
+ "upstream_status": 200
+ }
+ }),
+ };
+ }
+
+ if finish_reason.is_some() {
+ return SemanticSseObservation::SemanticEvent;
+ }
+ let Some(parts) = candidate
+ .get("content")
+ .and_then(|content| content.get("parts"))
+ .and_then(Value::as_array)
+ else {
+ continue;
+ };
+ if parts.iter().any(gemini_part_is_client_semantic) {
+ return SemanticSseObservation::SemanticEvent;
+ }
+ }
+
+ SemanticSseObservation::Pending
+}
+
+fn gemini_part_is_client_semantic(part: &Value) -> bool {
+ let Some(part) = part.as_object() else {
+ return true;
+ };
+ if part
+ .keys()
+ .any(|key| !matches!(key.as_str(), "text" | "thought" | "thoughtSignature"))
+ {
+ return true;
+ }
+ if part.get("thought").and_then(Value::as_bool) == Some(true) {
+ return false;
+ }
+ if part.keys().all(|key| key == "thoughtSignature") {
+ return false;
+ }
+ if part
+ .get("text")
+ .and_then(Value::as_str)
+ .is_some_and(|text| !text.is_empty())
+ {
+ return true;
+ }
+ false
+}
+
fn is_anthropic_semantic_event_type(event_type: &str) -> bool {
matches!(
event_type,
@@ -346,6 +518,13 @@ mod tests {
}
}
+ fn gemini_policy() -> StreamCommitPolicy {
+ StreamCommitPolicy::FirstGeminiSemanticEvent {
+ max_bytes: 16_384,
+ max_wait: Duration::from_millis(750),
+ }
+ }
+
#[test]
fn policy_selects_bounded_anthropic_gate_only_for_native_same_format_sse() {
let native = StreamCommitPolicy::for_response(
@@ -384,6 +563,112 @@ mod tests {
.commits_on_response_headers());
}
+ #[test]
+ fn policy_selects_bounded_gemini_gate_for_event_streams() {
+ let policy = StreamCommitPolicy::for_response(
+ true,
+ Some("text/event-stream"),
+ "gemini:generate_content",
+ "openai:responses",
+ false,
+ true,
+ false,
+ );
+
+ assert!(policy.is_gemini());
+ assert!(policy.requires_bounded_frame_wait());
+ assert_eq!(
+ policy.max_precommit_wait(),
+ Some(Duration::from_millis(750))
+ );
+ }
+
+ #[test]
+ fn gemini_gate_waits_through_thought_and_commits_on_text() {
+ let mut gate = StreamCommitGate::new(gemini_policy());
+ let thought = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"checking\"}]}}]}}\n\n";
+ let text = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"answer\"}]}}]}}\n\n";
+
+ assert_eq!(
+ gate.observe_provider_bytes(thought),
+ StreamPrecommitObservation::Pending
+ );
+ assert_eq!(
+ gate.observe_provider_bytes(text),
+ StreamPrecommitObservation::Commit
+ );
+ assert_eq!(gate.state(), StreamCommitState::Committed);
+ }
+
+ #[test]
+ fn gemini_gate_commits_on_function_call_even_with_thought_marker() {
+ let mut gate = StreamCommitGate::new(gemini_policy());
+ let tool_call = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"functionCall\":{\"name\":\"validate\",\"args\":{}}}]}}]}}\n\n";
+
+ assert_eq!(
+ gate.observe_provider_bytes(tool_call),
+ StreamPrecommitObservation::Commit
+ );
+ assert_eq!(gate.state(), StreamCommitState::Committed);
+ }
+
+ #[test]
+ fn gemini_gate_rejects_malformed_function_call_before_commit() {
+ let mut gate = StreamCommitGate::new(gemini_policy());
+ let thought = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"calling\"}]}}]}}\n\n";
+ let malformed = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thoughtSignature\":\"signature\",\"text\":\"\"}]},\"finishReason\":\"MALFORMED_FUNCTION_CALL\",\"finishMessage\":\"Malformed function call: Function call is empty - no input to parse.\"}]}}\n\n";
+
+ assert_eq!(
+ gate.observe_provider_bytes(thought),
+ StreamPrecommitObservation::Pending
+ );
+ let StreamPrecommitObservation::UpstreamError {
+ status_code,
+ body_json,
+ } = gate.observe_provider_bytes(malformed)
+ else {
+ panic!("malformed Gemini function call should fail before stream commit");
+ };
+
+ assert_eq!(status_code, 502);
+ assert_eq!(
+ body_json["error"]["code"],
+ "MALFORMED_FUNCTION_CALL"
+ );
+ assert_eq!(
+ body_json["error"]["message"],
+ "Malformed function call: Function call is empty - no input to parse."
+ );
+ assert_eq!(gate.state(), StreamCommitState::Terminal);
+ }
+
+ #[test]
+ fn gemini_gate_detects_malformed_function_call_across_chunk_boundaries() {
+ let malformed = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thoughtSignature\":\"signature\",\"text\":\"\"}]},\"finishReason\":\"MALFORMED_FUNCTION_CALL\",\"finishMessage\":\"empty call\"}]}}\r\n\r\n";
+
+ for split in 1..malformed.len() {
+ let mut gate = StreamCommitGate::new(gemini_policy());
+ let first_observation = gate.observe_provider_bytes(&malformed[..split]);
+ if !matches!(
+ first_observation,
+ StreamPrecommitObservation::UpstreamError {
+ status_code: 502,
+ ..
+ }
+ ) {
+ assert_eq!(first_observation, StreamPrecommitObservation::Pending);
+ assert!(matches!(
+ gate.observe_provider_bytes(&malformed[split..]),
+ StreamPrecommitObservation::UpstreamError {
+ status_code: 502,
+ ..
+ }
+ ));
+ }
+ assert_eq!(gate.state(), StreamCommitState::Terminal);
+ }
+ }
+
#[test]
fn gate_detects_anthropic_error_across_every_chunk_boundary() {
let event = b"event: error\r\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}\r\n\r\n";
diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs
index 80830455e..450f5bcac 100644
--- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs
+++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs
@@ -114,6 +114,7 @@ use crate::execution_runtime::{
use crate::execution_runtime::{
MAX_ERROR_BODY_BYTES, MAX_STREAM_PREFETCH_BYTES, MAX_STREAM_PREFETCH_FRAMES,
};
+use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::log_ids::short_request_id;
use crate::orchestration::{
apply_local_execution_effect, build_local_error_flow_metadata, classify_failure_disposition,
@@ -3778,6 +3779,11 @@ async fn execute_execution_runtime_stream_inner(
match acquire_provider_pool_execution_guard(state, &plan).await? {
ProviderPoolInFlightAdmission::Acquired(guard) => guard,
ProviderPoolInFlightAdmission::Saturated { limit } => {
+ record_local_runtime_candidate_skip_reason(
+ state,
+ trace_id,
+ "provider_key_concurrency_limit_reached",
+ );
if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
*retry_scope = AiAttemptRetryScope::Candidate;
}
@@ -6490,7 +6496,9 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
}
}
- let inspection = if stream_commit_policy.is_native_anthropic() {
+ let inspection = if stream_commit_policy.is_native_anthropic()
+ || stream_commit_policy.is_gemini()
+ {
StreamPrefetchInspection::NeedMore
} else {
inspect_prefetched_stream_body(
@@ -8231,7 +8239,8 @@ mod tests {
DirectPassthroughFinalizerCore, DirectPassthroughInlineBodyState, DirectPassthroughMode,
PostStopFrameReadBudget, PostStopLimitedStreamReader, ProviderStreamErrorInspection,
ANTHROPIC_POST_STOP_DRAIN_MAX_BYTES, GEMINI_FILES_DOWNLOAD_PLAN_KIND,
- OPENAI_CHAT_STREAM_PLAN_KIND, POST_STOP_MAX_EMPTY_CHUNKS_PER_POLL,
+ OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
+ POST_STOP_MAX_EMPTY_CHUNKS_PER_POLL,
};
use crate::control::GatewayControlDecision;
use crate::stage_metrics::RequestStageTrace;
@@ -8776,6 +8785,37 @@ mod tests {
}
}
+ fn antigravity_gemini_stream_plan(request_id: &str) -> ExecutionPlan {
+ ExecutionPlan {
+ request_id: request_id.to_string(),
+ candidate_id: Some(format!("candidate-{request_id}")),
+ provider_name: Some("antigravity".to_string()),
+ provider_id: format!("provider-{request_id}"),
+ endpoint_id: format!("endpoint-{request_id}"),
+ key_id: format!("key-{request_id}"),
+ method: "POST".to_string(),
+ url: "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent"
+ .to_string(),
+ headers: BTreeMap::from([
+ ("content-type".to_string(), "application/json".to_string()),
+ ("accept".to_string(), "text/event-stream".to_string()),
+ ]),
+ content_type: Some("application/json".to_string()),
+ content_encoding: None,
+ body: RequestBody::from_json(json!({
+ "model": "gemini-3.7-flash-tiered",
+ "contents": [{"role": "user", "parts": [{"text": "validate"}]}]
+ })),
+ stream: true,
+ client_api_format: "openai:responses".to_string(),
+ provider_api_format: "gemini:generate_content".to_string(),
+ model_name: Some("gemini-3.7-flash-tiered".to_string()),
+ proxy: None,
+ transport_profile: None,
+ timeouts: None,
+ }
+ }
+
struct StreamDropFlag(Arc);
impl Drop for StreamDropFlag {
@@ -10524,6 +10564,92 @@ mod tests {
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
+ #[tokio::test]
+ async fn malformed_antigravity_function_call_retries_before_stream_commit() {
+ let request_id = "req-antigravity-malformed-function-call";
+ let plan = antigravity_gemini_stream_plan(request_id);
+ let provider_catalog = provider_catalog_for_plan(
+ &plan,
+ Some(json!({
+ "failover_rules": {
+ "continue_status_codes": [502]
+ }
+ })),
+ );
+ let data_state = crate::data::GatewayDataState::with_provider_transport_reader_for_tests(
+ Arc::new(provider_catalog),
+ "development-key",
+ );
+ let state = AppState::new()
+ .expect("app state should build")
+ .with_data_state_for_tests(data_state);
+ let frame_stream = stream! {
+ yield Ok::(ndjson_frame(StreamFrame {
+ frame_type: StreamFrameType::Headers,
+ payload: StreamFramePayload::Headers {
+ status_code: 200,
+ headers: BTreeMap::from([(
+ "content-type".to_string(),
+ "text/event-stream".to_string(),
+ )]),
+ response_observation: None,
+ },
+ }));
+ for chunk in [
+ r#"data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"Validating the document."}]} }],"modelVersion":"gemini-3.7-flash-tiered"}}
+
+"#,
+ r#"data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"signature","text":""}]},"finishReason":"MALFORMED_FUNCTION_CALL","finishMessage":"Malformed function call: Function call is empty - no input to parse."}],"modelVersion":"gemini-3.7-flash-tiered"}}
+
+"#,
+ ] {
+ yield Ok::(ndjson_frame(StreamFrame {
+ frame_type: StreamFrameType::Data,
+ payload: StreamFramePayload::Data {
+ chunk_b64: None,
+ text: Some(chunk.to_string()),
+ },
+ }));
+ }
+ yield Ok::(ndjson_frame(StreamFrame::eof()));
+ }
+ .boxed();
+ let mut retry_scope = AiAttemptRetryScope::Provider;
+
+ let response = execute_stream_from_frame_stream_with_retry_scope(
+ &state,
+ plan,
+ "trace-antigravity-malformed-function-call",
+ &test_decision(),
+ OPENAI_RESPONSES_STREAM_PLAN_KIND,
+ Some("openai_responses_stream_success".to_string()),
+ Some(json!({
+ "request_id": request_id,
+ "candidate_id": format!("candidate-{request_id}"),
+ "candidate_index": 0,
+ "retry_index": 0,
+ "provider_api_format": "gemini:generate_content",
+ "client_api_format": "openai:responses",
+ "needs_conversion": true
+ })),
+ crate::clock::current_unix_ms(),
+ Instant::now(),
+ RequestStageTrace::from_env(),
+ true,
+ frame_stream,
+ false,
+ None,
+ Some(&mut retry_scope),
+ None,
+ None,
+ )
+ .await
+ .expect("malformed Antigravity stream should resolve through failover");
+
+ assert!(response.is_none());
+ assert_eq!(retry_scope, AiAttemptRetryScope::Candidate);
+ }
+
fn tunnel_proxy_snapshot(base_url: String) -> aether_contracts::ProxySnapshot {
aether_contracts::ProxySnapshot {
enabled: Some(true),
diff --git a/apps/aether-gateway/src/execution_runtime/sync/execution.rs b/apps/aether-gateway/src/execution_runtime/sync/execution.rs
index 84d67a25a..49f2c16ac 100644
--- a/apps/aether-gateway/src/execution_runtime/sync/execution.rs
+++ b/apps/aether-gateway/src/execution_runtime/sync/execution.rs
@@ -69,6 +69,7 @@ use crate::execution_runtime::{
local_failover_response_text, resolve_core_sync_error_finalize_report_kind,
should_fallback_to_control_sync, should_finalize_sync_response, LocalFailoverDecision,
};
+use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::log_ids::short_request_id;
use crate::orchestration::{
apply_local_execution_effect, build_local_error_flow_metadata,
@@ -2002,6 +2003,11 @@ async fn execute_execution_runtime_sync_impl(
{
ProviderPoolInFlightAdmission::Acquired(guard) => guard,
ProviderPoolInFlightAdmission::Saturated { limit } => {
+ record_local_runtime_candidate_skip_reason(
+ state,
+ trace_id,
+ "provider_key_concurrency_limit_reached",
+ );
if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
*retry_scope = AiAttemptRetryScope::Candidate;
}
diff --git a/apps/aether-gateway/src/executor/outcome.rs b/apps/aether-gateway/src/executor/outcome.rs
index a9a255743..8c4efe2e6 100644
--- a/apps/aether-gateway/src/executor/outcome.rs
+++ b/apps/aether-gateway/src/executor/outcome.rs
@@ -111,6 +111,22 @@ impl LocalExecutionRuntimeMissContext {
})
}
+ pub(crate) fn all_candidates_skipped_for_reasons(&self, reasons: &[&str]) -> bool {
+ if reasons.is_empty() || self.candidate_contexts.is_empty() {
+ return false;
+ }
+
+ self.candidate_contexts.iter().all(|candidate| {
+ candidate.candidate.status == RequestCandidateStatus::Skipped
+ && candidate
+ .candidate
+ .skip_reason
+ .as_deref()
+ .map(str::trim)
+ .is_some_and(|value| reasons.contains(&value))
+ })
+ }
+
pub(crate) fn candidate_summary(&self) -> Option {
const MAX_ITEMS: usize = 5;
diff --git a/apps/aether-gateway/src/handlers/proxy/mod.rs b/apps/aether-gateway/src/handlers/proxy/mod.rs
index 0fe8373dc..ba319c275 100644
--- a/apps/aether-gateway/src/handlers/proxy/mod.rs
+++ b/apps/aether-gateway/src/handlers/proxy/mod.rs
@@ -105,6 +105,10 @@ const LOCAL_EXECUTION_LOOP_DETECTED_DETAIL: &str =
"Gateway detected an execution runtime request loop back into the local frontdoor";
const AUTH_API_KEY_CONCURRENCY_LIMIT_REACHED_DETAIL: &str =
"当前调用方 API Key 并发请求数已达上限,请稍后重试";
+const PROVIDER_KEY_CAPACITY_LIMIT_REACHED_DETAIL: &str =
+ "所有可用上游账号当前均已达到并发或 RPM 上限,请稍后重试";
+const PROVIDER_KEY_CAPACITY_LIMIT_SKIP_REASONS: &[&str] =
+ &["provider_key_concurrency_limit_reached", "key_rpm_exhausted"];
const LOCAL_EXECUTION_PLANNING_TIMEOUT_DETAIL: &str =
"当前 AI 请求在本地执行规划阶段超时,请稍后重试";
const EXECUTION_PATH_TUNNEL_AFFINITY_FORWARD: &str = "tunnel_affinity_forward";
@@ -1908,12 +1912,23 @@ async fn proxy_request_inner(
.all_candidates_skipped_for_reason(AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON)
|| local_execution_runtime_miss_context
.all_candidates_skipped_for_reason(LEGACY_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON);
- let local_execution_runtime_miss_detail = (!auth_api_key_concurrency_limited)
- .then(|| {
+ let provider_key_capacity_limited = local_execution_runtime_miss_diagnostic
+ .as_ref()
+ .map(|diagnostic| diagnostic_is_provider_key_capacity_limited(Some(diagnostic)))
+ .unwrap_or_else(|| {
local_execution_runtime_miss_context
- .all_provider_request_body_build_failures_detail()
+ .all_candidates_skipped_for_reasons(PROVIDER_KEY_CAPACITY_LIMIT_SKIP_REASONS)
+ });
+ let local_execution_runtime_miss_detail = provider_key_capacity_limited
+ .then_some(PROVIDER_KEY_CAPACITY_LIMIT_REACHED_DETAIL.to_string())
+ .or_else(|| {
+ (!auth_api_key_concurrency_limited)
+ .then(|| {
+ local_execution_runtime_miss_context
+ .all_provider_request_body_build_failures_detail()
+ })
+ .flatten()
})
- .flatten()
.or_else(|| {
local_execution_runtime_miss_detail(
control_decision,
@@ -2029,7 +2044,7 @@ async fn proxy_request_inner(
let mut response = build_local_http_error_response(
&trace_id,
control_decision,
- http::StatusCode::SERVICE_UNAVAILABLE,
+ local_execution_runtime_miss_status(provider_key_capacity_limited),
local_execution_runtime_miss_client_message(
local_execution_runtime_miss_detail.as_str(),
)
@@ -2355,6 +2370,31 @@ fn diagnostic_is_auth_api_key_concurrency_limited(
}))
}
+fn diagnostic_is_provider_key_capacity_limited(
+ diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
+) -> bool {
+ let Some(diagnostic) = diagnostic else {
+ return false;
+ };
+ PROVIDER_KEY_CAPACITY_LIMIT_SKIP_REASONS.contains(&diagnostic.reason.as_str())
+ || (diagnostic.candidate_count.is_some_and(|candidate_count| {
+ candidate_count > 0
+ && diagnostic.skipped_candidate_count.unwrap_or(0) >= candidate_count
+ })
+ && !diagnostic.skip_reasons.is_empty()
+ && diagnostic.skip_reasons.iter().all(|(reason, count)| {
+ PROVIDER_KEY_CAPACITY_LIMIT_SKIP_REASONS.contains(&reason.as_str()) && *count > 0
+ }))
+}
+
+fn local_execution_runtime_miss_status(provider_key_capacity_limited: bool) -> http::StatusCode {
+ if provider_key_capacity_limited {
+ http::StatusCode::TOO_MANY_REQUESTS
+ } else {
+ http::StatusCode::SERVICE_UNAVAILABLE
+ }
+}
+
fn local_execution_runtime_miss_route_detail(
decision: Option<&GatewayControlDecision>,
) -> Option<&'static str> {
@@ -2393,14 +2433,15 @@ mod tests {
use super::{
api_key_remote_ip_allowed, buffer_and_normalize_request_body,
- diagnostic_is_auth_api_key_concurrency_limited, local_execution_runtime_miss_detail,
- owner_forward_request_is_stream, restore_redacted_stream_execution_response,
- restore_redacted_sync_execution_response, routing_overlay_allows_affinity_target,
- GatewayControlDecision, LocalExecutionRuntimeMissDiagnostic, RequestBodyBufferError,
- RequestBodyBufferPolicy,
+ diagnostic_is_auth_api_key_concurrency_limited,
+ diagnostic_is_provider_key_capacity_limited, local_execution_runtime_miss_detail,
+ local_execution_runtime_miss_status, owner_forward_request_is_stream,
+ restore_redacted_stream_execution_response, restore_redacted_sync_execution_response,
+ routing_overlay_allows_affinity_target, GatewayControlDecision,
+ LocalExecutionRuntimeMissDiagnostic, RequestBodyBufferError, RequestBodyBufferPolicy,
};
use axum::body::{to_bytes, Body, Bytes};
- use axum::http::{header, HeaderMap, HeaderValue, Method, Response};
+ use axum::http::{header, HeaderMap, HeaderValue, Method, Response, StatusCode};
use serde_json::json;
use tokio::sync::Semaphore;
@@ -2880,6 +2921,45 @@ mod tests {
Some("当前调用方 API Key 并发请求数已达上限,请稍后重试")
);
}
+
+ #[test]
+ fn provider_key_capacity_requires_every_skip_reason_to_be_capacity_related() {
+ let capacity_limited = LocalExecutionRuntimeMissDiagnostic {
+ reason: "candidate_evaluation_incomplete".to_string(),
+ candidate_count: Some(2),
+ skipped_candidate_count: Some(2),
+ skip_reasons: std::collections::BTreeMap::from([
+ ("provider_key_concurrency_limit_reached".to_string(), 1),
+ ("key_rpm_exhausted".to_string(), 1),
+ ]),
+ ..LocalExecutionRuntimeMissDiagnostic::default()
+ };
+ let mixed_failure = LocalExecutionRuntimeMissDiagnostic {
+ reason: "all_candidates_skipped".to_string(),
+ candidate_count: Some(2),
+ skipped_candidate_count: Some(2),
+ skip_reasons: std::collections::BTreeMap::from([
+ ("provider_key_concurrency_limit_reached".to_string(), 1),
+ ("account_quota_exhausted".to_string(), 1),
+ ]),
+ ..LocalExecutionRuntimeMissDiagnostic::default()
+ };
+
+ assert!(diagnostic_is_provider_key_capacity_limited(Some(
+ &capacity_limited
+ )));
+ assert!(!diagnostic_is_provider_key_capacity_limited(Some(
+ &mixed_failure
+ )));
+ assert_eq!(
+ local_execution_runtime_miss_status(true),
+ StatusCode::TOO_MANY_REQUESTS
+ );
+ assert_eq!(
+ local_execution_runtime_miss_status(false),
+ StatusCode::SERVICE_UNAVAILABLE
+ );
+ }
}
#[path = "finalize.rs"]
diff --git a/apps/aether-gateway/src/request_candidate_runtime.rs b/apps/aether-gateway/src/request_candidate_runtime.rs
index 51b0671a8..9c84e3bbe 100644
--- a/apps/aether-gateway/src/request_candidate_runtime.rs
+++ b/apps/aether-gateway/src/request_candidate_runtime.rs
@@ -363,7 +363,7 @@ pub(crate) async fn record_local_request_candidate_status(
report_context: Option<&Value>,
status_update: SchedulerRequestCandidateStatusUpdate,
) {
- let Some(record) =
+ let Some(mut record) =
build_local_request_candidate_status_record(LocalRequestCandidateStatusRecordInput {
plan,
report_context,
@@ -372,6 +372,10 @@ pub(crate) async fn record_local_request_candidate_status(
else {
return;
};
+ record.skip_reason = local_request_candidate_skip_reason(
+ record.status,
+ record.error_type.as_deref(),
+ );
persist_local_request_candidate_status_record(state, record).await;
}
@@ -429,6 +433,7 @@ fn build_local_request_candidate_status_snapshot_record(
started_at_unix_ms,
finished_at_unix_ms,
} = status_update;
+ let skip_reason = local_request_candidate_skip_reason(status, error_type.as_deref());
UpsertRequestCandidateRecord {
id: snapshot.candidate_id.clone(),
request_id: snapshot.request_id.clone(),
@@ -442,7 +447,7 @@ fn build_local_request_candidate_status_snapshot_record(
endpoint_id: Some(snapshot.endpoint_id.clone()),
key_id: Some(snapshot.key_id.clone()),
status,
- skip_reason: None,
+ skip_reason,
is_cached: None,
status_code,
error_type,
@@ -457,6 +462,17 @@ fn build_local_request_candidate_status_snapshot_record(
}
}
+fn local_request_candidate_skip_reason(
+ status: RequestCandidateStatus,
+ error_type: Option<&str>,
+) -> Option {
+ (status == RequestCandidateStatus::Skipped)
+ .then_some(error_type)
+ .flatten()
+ .filter(|reason| *reason == "provider_key_concurrency_limit_reached")
+ .map(ToOwned::to_owned)
+}
+
pub(crate) fn try_enqueue_local_request_candidate_status_snapshot(
state: &(impl RequestCandidateRuntimeWriter + ?Sized),
snapshot: &LocalRequestCandidateStatusSnapshot,
@@ -1078,6 +1094,40 @@ mod tests {
assert_eq!(records[0].status_code, Some(200));
}
+ #[test]
+ fn saturated_provider_key_snapshot_persists_capacity_skip_reason() {
+ let mut plan = sample_plan();
+ plan.candidate_id = Some("candidate-provider-key-saturated".to_string());
+ let snapshot = snapshot_local_request_candidate_status(&plan, None)
+ .expect("candidate snapshot should build");
+ let writer = SynchronousStatusWriter::default();
+
+ try_enqueue_local_request_candidate_status_snapshot(
+ &writer,
+ &snapshot,
+ SchedulerRequestCandidateStatusUpdate {
+ status: RequestCandidateStatus::Skipped,
+ status_code: Some(429),
+ error_type: Some("provider_key_concurrency_limit_reached".to_string()),
+ error_message: Some("provider key concurrency limit reached: 1".to_string()),
+ latency_ms: Some(0),
+ started_at_unix_ms: Some(123),
+ finished_at_unix_ms: Some(123),
+ },
+ )
+ .expect("saturated status should use the synchronous enqueue path");
+
+ let records = writer
+ .records
+ .lock()
+ .expect("synchronous status records lock");
+ assert_eq!(records.len(), 1);
+ assert_eq!(
+ records[0].skip_reason.as_deref(),
+ Some("provider_key_concurrency_limit_reached")
+ );
+ }
+
fn sample_minimal_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
SchedulerMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs
index 2f009554c..79be931b0 100644
--- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs
+++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs
@@ -376,6 +376,15 @@ impl StreamingStandardTerminalObserver {
finish_reason,
usage,
} => {
+ if let Some(parser_error) = finish_reason
+ .as_deref()
+ .filter(|reason| !canonical_stream_finish_reason_is_supported(reason))
+ .map(|reason| {
+ format!("unsupported provider stream finish reason: {reason}")
+ })
+ {
+ summary.parser_error.get_or_insert(parser_error);
+ }
summary.finish_reason = finish_reason;
summary.standardized_usage = usage.map(standardized_usage_from_canonical);
summary.observed_finish = true;
@@ -785,6 +794,50 @@ mod tests {
format!("event: {event}\n").into_bytes()
}
+ #[test]
+ fn terminal_observer_marks_malformed_gemini_function_call_as_failure() {
+ let context = report_context("gemini:generate_content", "openai:responses");
+ let mut observer = StreamingStandardTerminalObserver::default();
+ observer
+ .push_line(
+ &context,
+ data_line(json!({
+ "response": {
+ "responseId": "resp_malformed_tool_call",
+ "modelVersion": "gemini-3.7-flash-tiered",
+ "candidates": [{
+ "index": 0,
+ "content": {
+ "role": "model",
+ "parts": [{"thoughtSignature": "signature", "text": ""}]
+ },
+ "finishReason": "MALFORMED_FUNCTION_CALL",
+ "finishMessage": "Malformed function call: Function call is empty - no input to parse."
+ }]
+ },
+ "responseId": "resp_malformed_tool_call"
+ })),
+ )
+ .expect("Gemini terminal frame should parse");
+
+ let summary = observer
+ .finish(&context)
+ .expect("terminal observation should finish")
+ .expect("Gemini terminal frame should produce a summary");
+
+ assert!(summary.observed_finish);
+ assert_eq!(
+ summary.finish_reason.as_deref(),
+ Some("MALFORMED_FUNCTION_CALL")
+ );
+ assert_eq!(
+ summary.parser_error.as_deref(),
+ Some(
+ "unsupported provider stream finish reason: MALFORMED_FUNCTION_CALL"
+ )
+ );
+ }
+
#[test]
fn event_only_stream_types_convert_across_standard_formats() {
let responses_payload = json!({
From 3d87bbf230c5919bd2b21210a304dbeece1cf754 Mon Sep 17 00:00:00 2001
From: ZheFox <77232781+zhefox@users.noreply.github.com>
Date: Tue, 1 Sep 2026 19:31:13 +0800
Subject: [PATCH 03/99] style(rust): apply workspace formatting
---
.../src/execution_runtime/stream/commit_policy.rs | 5 +----
.../src/execution_runtime/stream/execution.rs | 5 ++---
.../src/execution_runtime/sync/execution.rs | 2 +-
apps/aether-gateway/src/handlers/proxy/mod.rs | 9 +++++----
apps/aether-gateway/src/request_candidate_runtime.rs | 6 ++----
.../src/formats/shared/stream_core/format_matrix.rs | 8 ++------
6 files changed, 13 insertions(+), 22 deletions(-)
diff --git a/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs b/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs
index a8048b286..3cbf9f641 100644
--- a/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs
+++ b/apps/aether-gateway/src/execution_runtime/stream/commit_policy.rs
@@ -631,10 +631,7 @@ mod tests {
};
assert_eq!(status_code, 502);
- assert_eq!(
- body_json["error"]["code"],
- "MALFORMED_FUNCTION_CALL"
- );
+ assert_eq!(body_json["error"]["code"], "MALFORMED_FUNCTION_CALL");
assert_eq!(
body_json["error"]["message"],
"Malformed function call: Function call is empty - no input to parse."
diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs
index 450f5bcac..587ed1a8f 100644
--- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs
+++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs
@@ -71,6 +71,7 @@ use crate::ai_serving::api::{
UPSTREAM_IS_STREAM_KEY,
};
use crate::ai_serving::is_openai_responses_family_format;
+use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::api::response::{
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
};
@@ -114,7 +115,6 @@ use crate::execution_runtime::{
use crate::execution_runtime::{
MAX_ERROR_BODY_BYTES, MAX_STREAM_PREFETCH_BYTES, MAX_STREAM_PREFETCH_FRAMES,
};
-use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::log_ids::short_request_id;
use crate::orchestration::{
apply_local_execution_effect, build_local_error_flow_metadata, classify_failure_disposition,
@@ -8794,8 +8794,7 @@ mod tests {
endpoint_id: format!("endpoint-{request_id}"),
key_id: format!("key-{request_id}"),
method: "POST".to_string(),
- url: "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent"
- .to_string(),
+ url: "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent".to_string(),
headers: BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("accept".to_string(), "text/event-stream".to_string()),
diff --git a/apps/aether-gateway/src/execution_runtime/sync/execution.rs b/apps/aether-gateway/src/execution_runtime/sync/execution.rs
index 49f2c16ac..2b851598a 100644
--- a/apps/aether-gateway/src/execution_runtime/sync/execution.rs
+++ b/apps/aether-gateway/src/execution_runtime/sync/execution.rs
@@ -34,6 +34,7 @@ use crate::ai_serving::api::{
implicit_sync_finalize_report_kind, maybe_build_sync_finalize_outcome, LocalCoreSyncErrorKind,
LocalCoreSyncFinalizeOutcome,
};
+use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::api::response::{
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
build_client_response_from_parts_with_mutator,
@@ -69,7 +70,6 @@ use crate::execution_runtime::{
local_failover_response_text, resolve_core_sync_error_finalize_report_kind,
should_fallback_to_control_sync, should_finalize_sync_response, LocalFailoverDecision,
};
-use crate::ai_serving::record_local_runtime_candidate_skip_reason;
use crate::log_ids::short_request_id;
use crate::orchestration::{
apply_local_execution_effect, build_local_error_flow_metadata,
diff --git a/apps/aether-gateway/src/handlers/proxy/mod.rs b/apps/aether-gateway/src/handlers/proxy/mod.rs
index ba319c275..60f0d9ed8 100644
--- a/apps/aether-gateway/src/handlers/proxy/mod.rs
+++ b/apps/aether-gateway/src/handlers/proxy/mod.rs
@@ -107,8 +107,10 @@ const AUTH_API_KEY_CONCURRENCY_LIMIT_REACHED_DETAIL: &str =
"当前调用方 API Key 并发请求数已达上限,请稍后重试";
const PROVIDER_KEY_CAPACITY_LIMIT_REACHED_DETAIL: &str =
"所有可用上游账号当前均已达到并发或 RPM 上限,请稍后重试";
-const PROVIDER_KEY_CAPACITY_LIMIT_SKIP_REASONS: &[&str] =
- &["provider_key_concurrency_limit_reached", "key_rpm_exhausted"];
+const PROVIDER_KEY_CAPACITY_LIMIT_SKIP_REASONS: &[&str] = &[
+ "provider_key_concurrency_limit_reached",
+ "key_rpm_exhausted",
+];
const LOCAL_EXECUTION_PLANNING_TIMEOUT_DETAIL: &str =
"当前 AI 请求在本地执行规划阶段超时,请稍后重试";
const EXECUTION_PATH_TUNNEL_AFFINITY_FORWARD: &str = "tunnel_affinity_forward";
@@ -2380,8 +2382,7 @@ fn diagnostic_is_provider_key_capacity_limited(
|| (diagnostic.candidate_count.is_some_and(|candidate_count| {
candidate_count > 0
&& diagnostic.skipped_candidate_count.unwrap_or(0) >= candidate_count
- })
- && !diagnostic.skip_reasons.is_empty()
+ }) && !diagnostic.skip_reasons.is_empty()
&& diagnostic.skip_reasons.iter().all(|(reason, count)| {
PROVIDER_KEY_CAPACITY_LIMIT_SKIP_REASONS.contains(&reason.as_str()) && *count > 0
}))
diff --git a/apps/aether-gateway/src/request_candidate_runtime.rs b/apps/aether-gateway/src/request_candidate_runtime.rs
index 9c84e3bbe..3f9f9f7a2 100644
--- a/apps/aether-gateway/src/request_candidate_runtime.rs
+++ b/apps/aether-gateway/src/request_candidate_runtime.rs
@@ -372,10 +372,8 @@ pub(crate) async fn record_local_request_candidate_status(
else {
return;
};
- record.skip_reason = local_request_candidate_skip_reason(
- record.status,
- record.error_type.as_deref(),
- );
+ record.skip_reason =
+ local_request_candidate_skip_reason(record.status, record.error_type.as_deref());
persist_local_request_candidate_status_record(state, record).await;
}
diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs
index 79be931b0..d81d47247 100644
--- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs
+++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs
@@ -379,9 +379,7 @@ impl StreamingStandardTerminalObserver {
if let Some(parser_error) = finish_reason
.as_deref()
.filter(|reason| !canonical_stream_finish_reason_is_supported(reason))
- .map(|reason| {
- format!("unsupported provider stream finish reason: {reason}")
- })
+ .map(|reason| format!("unsupported provider stream finish reason: {reason}"))
{
summary.parser_error.get_or_insert(parser_error);
}
@@ -832,9 +830,7 @@ mod tests {
);
assert_eq!(
summary.parser_error.as_deref(),
- Some(
- "unsupported provider stream finish reason: MALFORMED_FUNCTION_CALL"
- )
+ Some("unsupported provider stream finish reason: MALFORMED_FUNCTION_CALL")
);
}
From 5059093d29c12e6e359739b619c151a9a3b40ee1 Mon Sep 17 00:00:00 2001
From: fawney
Date: Tue, 1 Sep 2026 20:08:36 +0800
Subject: [PATCH 04/99] feat(vscodex): add remote Codex collaboration module
---
.dockerignore | 4 +
.env.example | 6 +
.github/workflows/deploy-pages.yml | 10 +-
.github/workflows/release.yml | 84 +-
.gitignore | 2 +
Cargo.lock | 1 +
Dockerfile.app.local | 9 +
Dockerfile.app.release-local | 9 +
README.md | 6 +
aether-vscodex/.dockerignore | 10 +
aether-vscodex/.gitignore | 8 +
aether-vscodex/Dockerfile | 26 +
aether-vscodex/README.md | 283 +
aether-vscodex/cloud/server.js | 727 ++
aether-vscodex/docker-compose.aether.yml | 37 +
aether-vscodex/docs/cloud-security.md | 20 +
aether-vscodex/fixtures/fake-app-server.cjs | 59 +
aether-vscodex/package-lock.json | 39 +
aether-vscodex/package.json | 23 +
aether-vscodex/public/app.js | 8199 +++++++++++++++++
aether-vscodex/public/embed-bridge.js | 112 +
aether-vscodex/public/i18n.js | 541 ++
aether-vscodex/public/index.html | 303 +
aether-vscodex/public/style.css | 1817 ++++
aether-vscodex/relay/server.js | 2613 ++++++
aether-vscodex/test/adapter-safety.test.js | 745 ++
aether-vscodex/test/cloud-server.test.js | 298 +
aether-vscodex/test/codex-ipc-adapter.test.js | 2247 +++++
aether-vscodex/test/codex-ipc.test.js | 209 +
aether-vscodex/test/codex-path.test.js | 57 +
aether-vscodex/test/composite-relay.test.js | 105 +
.../test/extension-i18n-build.test.js | 34 +
aether-vscodex/test/local-relay.test.js | 120 +
aether-vscodex/test/public-embed-i18n.test.js | 194 +
aether-vscodex/test/relay-client.test.js | 206 +
aether-vscodex/test/relay.test.js | 1610 ++++
.../test/switchable-agent-adapter.test.js | 387 +
aether-vscodex/vscode-extension/.gitignore | 3 +
aether-vscodex/vscode-extension/.vscodeignore | 9 +
aether-vscodex/vscode-extension/LICENSE | 21 +
aether-vscodex/vscode-extension/README.md | 237 +
.../vscode-extension/l10n/bundle.l10n.json | 56 +
.../l10n/bundle.l10n.zh-cn.json | 56 +
.../vscode-extension/package-lock.json | 94 +
aether-vscodex/vscode-extension/package.json | 211 +
.../vscode-extension/package.nls.json | 38 +
.../vscode-extension/package.nls.zh-cn.json | 38 +
.../scripts/sync-local-relay.cjs | 19 +
aether-vscodex/vscode-extension/src/bridge.ts | 46 +
aether-vscodex/vscode-extension/src/cli.ts | 30 +
.../vscode-extension/src/codexAgentAdapter.ts | 1914 ++++
.../vscode-extension/src/codexIpc.ts | 947 ++
.../src/codexIpcAgentAdapter.ts | 4253 +++++++++
.../vscode-extension/src/codexPath.ts | 152 +
.../vscode-extension/src/compositeRelay.ts | 131 +
.../vscode-extension/src/extension.ts | 574 ++
aether-vscodex/vscode-extension/src/index.ts | 11 +
.../vscode-extension/src/jsonlRpc.ts | 268 +
.../vscode-extension/src/localRelay.ts | 193 +
.../vscode-extension/src/protocol.ts | 496 +
.../vscode-extension/src/relayClient.ts | 413 +
.../vscode-extension/src/relayHost.ts | 650 ++
.../src/switchableAgentAdapter.ts | 425 +
aether-vscodex/vscode-extension/tsconfig.json | 28 +
aether-vscodex/web/.gitignore | 3 +
aether-vscodex/web/index.html | 13 +
aether-vscodex/web/package-lock.json | 2680 ++++++
aether-vscodex/web/package.json | 28 +
aether-vscodex/web/src/App.vue | 7 +
.../web/src/components/CodexSurface.vue | 269 +
aether-vscodex/web/src/main.ts | 52 +
.../web/src/runtime/request-template.ts | 34 +
aether-vscodex/web/src/vite-env.d.ts | 12 +
aether-vscodex/web/tests/CodexSurface.test.ts | 49 +
aether-vscodex/web/tsconfig.app.json | 19 +
aether-vscodex/web/tsconfig.json | 7 +
aether-vscodex/web/tsconfig.node.json | 17 +
aether-vscodex/web/vite.config.ts | 45 +
apps/aether-gateway/Cargo.toml | 1 +
apps/aether-gateway/src/api/backend/public.rs | 6 +-
.../src/control/route/public_support.rs | 59 +
.../src/control/tests/public_support.rs | 63 +
apps/aether-gateway/src/handlers/proxy/mod.rs | 1 +
.../aether-gateway/src/handlers/public/mod.rs | 2 +-
.../src/handlers/public/support.rs | 10 +
.../src/handlers/public/support/user_me.rs | 5 +-
.../handlers/public/support/user_me_routes.rs | 15 +-
.../public/support/user_me_vscodex.rs | 931 ++
.../src/handlers/shared/request_utils.rs | 8 +-
.../src/tests/frontdoor/public_support.rs | 2 +
.../tests/frontdoor/public_support/vscodex.rs | 578 ++
frontend/package.json | 4 +
frontend/scripts/sync-vscodex.mjs | 16 +
frontend/src/api/__tests__/vscodex.spec.ts | 116 +
frontend/src/api/vscodex.ts | 111 +
frontend/src/i18n/messages.ts | 62 +
.../main-layout/__tests__/navigation.spec.ts | 42 +
.../src/layouts/main-layout/navigation.ts | 3 +
frontend/src/router/routes/dashboard.ts | 5 +
frontend/src/views/user/VscodeControl.vue | 559 ++
.../user/__tests__/VscodeControl.spec.ts | 272 +
frontend/vite.config.ts | 1 +
102 files changed, 38569 insertions(+), 11 deletions(-)
create mode 100644 aether-vscodex/.dockerignore
create mode 100644 aether-vscodex/.gitignore
create mode 100644 aether-vscodex/Dockerfile
create mode 100644 aether-vscodex/README.md
create mode 100644 aether-vscodex/cloud/server.js
create mode 100644 aether-vscodex/docker-compose.aether.yml
create mode 100644 aether-vscodex/docs/cloud-security.md
create mode 100644 aether-vscodex/fixtures/fake-app-server.cjs
create mode 100644 aether-vscodex/package-lock.json
create mode 100644 aether-vscodex/package.json
create mode 100644 aether-vscodex/public/app.js
create mode 100644 aether-vscodex/public/embed-bridge.js
create mode 100644 aether-vscodex/public/i18n.js
create mode 100644 aether-vscodex/public/index.html
create mode 100644 aether-vscodex/public/style.css
create mode 100644 aether-vscodex/relay/server.js
create mode 100644 aether-vscodex/test/adapter-safety.test.js
create mode 100644 aether-vscodex/test/cloud-server.test.js
create mode 100644 aether-vscodex/test/codex-ipc-adapter.test.js
create mode 100644 aether-vscodex/test/codex-ipc.test.js
create mode 100644 aether-vscodex/test/codex-path.test.js
create mode 100644 aether-vscodex/test/composite-relay.test.js
create mode 100644 aether-vscodex/test/extension-i18n-build.test.js
create mode 100644 aether-vscodex/test/local-relay.test.js
create mode 100644 aether-vscodex/test/public-embed-i18n.test.js
create mode 100644 aether-vscodex/test/relay-client.test.js
create mode 100644 aether-vscodex/test/relay.test.js
create mode 100644 aether-vscodex/test/switchable-agent-adapter.test.js
create mode 100644 aether-vscodex/vscode-extension/.gitignore
create mode 100644 aether-vscodex/vscode-extension/.vscodeignore
create mode 100644 aether-vscodex/vscode-extension/LICENSE
create mode 100644 aether-vscodex/vscode-extension/README.md
create mode 100644 aether-vscodex/vscode-extension/l10n/bundle.l10n.json
create mode 100644 aether-vscodex/vscode-extension/l10n/bundle.l10n.zh-cn.json
create mode 100644 aether-vscodex/vscode-extension/package-lock.json
create mode 100644 aether-vscodex/vscode-extension/package.json
create mode 100644 aether-vscodex/vscode-extension/package.nls.json
create mode 100644 aether-vscodex/vscode-extension/package.nls.zh-cn.json
create mode 100644 aether-vscodex/vscode-extension/scripts/sync-local-relay.cjs
create mode 100644 aether-vscodex/vscode-extension/src/bridge.ts
create mode 100644 aether-vscodex/vscode-extension/src/cli.ts
create mode 100644 aether-vscodex/vscode-extension/src/codexAgentAdapter.ts
create mode 100644 aether-vscodex/vscode-extension/src/codexIpc.ts
create mode 100644 aether-vscodex/vscode-extension/src/codexIpcAgentAdapter.ts
create mode 100644 aether-vscodex/vscode-extension/src/codexPath.ts
create mode 100644 aether-vscodex/vscode-extension/src/compositeRelay.ts
create mode 100644 aether-vscodex/vscode-extension/src/extension.ts
create mode 100644 aether-vscodex/vscode-extension/src/index.ts
create mode 100644 aether-vscodex/vscode-extension/src/jsonlRpc.ts
create mode 100644 aether-vscodex/vscode-extension/src/localRelay.ts
create mode 100644 aether-vscodex/vscode-extension/src/protocol.ts
create mode 100644 aether-vscodex/vscode-extension/src/relayClient.ts
create mode 100644 aether-vscodex/vscode-extension/src/relayHost.ts
create mode 100644 aether-vscodex/vscode-extension/src/switchableAgentAdapter.ts
create mode 100644 aether-vscodex/vscode-extension/tsconfig.json
create mode 100644 aether-vscodex/web/.gitignore
create mode 100644 aether-vscodex/web/index.html
create mode 100644 aether-vscodex/web/package-lock.json
create mode 100644 aether-vscodex/web/package.json
create mode 100644 aether-vscodex/web/src/App.vue
create mode 100644 aether-vscodex/web/src/components/CodexSurface.vue
create mode 100644 aether-vscodex/web/src/main.ts
create mode 100644 aether-vscodex/web/src/runtime/request-template.ts
create mode 100644 aether-vscodex/web/src/vite-env.d.ts
create mode 100644 aether-vscodex/web/tests/CodexSurface.test.ts
create mode 100644 aether-vscodex/web/tsconfig.app.json
create mode 100644 aether-vscodex/web/tsconfig.json
create mode 100644 aether-vscodex/web/tsconfig.node.json
create mode 100644 aether-vscodex/web/vite.config.ts
create mode 100644 apps/aether-gateway/src/handlers/public/support/user_me_vscodex.rs
create mode 100644 apps/aether-gateway/src/tests/frontdoor/public_support/vscodex.rs
create mode 100644 frontend/scripts/sync-vscodex.mjs
create mode 100644 frontend/src/api/__tests__/vscodex.spec.ts
create mode 100644 frontend/src/api/vscodex.ts
create mode 100644 frontend/src/views/user/VscodeControl.vue
create mode 100644 frontend/src/views/user/__tests__/VscodeControl.spec.ts
diff --git a/.dockerignore b/.dockerignore
index 93a95eeb5..28cd2806a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,6 +9,10 @@ target/
frontend/node_modules/
frontend/dist/
frontend/.vite/
+aether-vscodex/web/node_modules/
+aether-vscodex/web/dist/
+aether-vscodex/vscode-extension/node_modules/
+aether-vscodex/vscode-extension/dist/
# Development
.git/
diff --git a/.env.example b/.env.example
index 931d4109b..3c0a0bf75 100644
--- a/.env.example
+++ b/.env.example
@@ -63,6 +63,12 @@ ADMIN_USERNAME=admin123456
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
+# VS Code Codex 云端协同(仅在叠加 aether-vscodex/docker-compose.aether.yml 时需要)
+# 内部 token 至少 24 字节,建议使用:openssl rand -base64 32
+# AETHER_VSCODEX_INTERNAL_TOKEN=replace-with-a-long-random-secret
+# AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws
+# AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com
+
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index b57dfeb1b..d9a615cf4 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -53,7 +53,15 @@ jobs:
with:
node-version: '22'
cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
+ cache-dependency-path: |
+ frontend/package-lock.json
+ aether-vscodex/web/package-lock.json
+
+ - name: Build aether-vscodex web
+ working-directory: aether-vscodex/web
+ run: |
+ npm ci
+ npm run build
- name: Install dependencies
working-directory: frontend
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 33256e04d..8114ff833 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -77,7 +77,15 @@ jobs:
with:
node-version: 22
cache: npm
- cache-dependency-path: frontend/package-lock.json
+ cache-dependency-path: |
+ frontend/package-lock.json
+ aether-vscodex/web/package-lock.json
+
+ - name: Build aether-vscodex web
+ working-directory: aether-vscodex/web
+ run: |
+ npm ci
+ npm run build
- name: Install & build
working-directory: frontend
@@ -93,6 +101,71 @@ jobs:
if-no-files-found: error
retention-days: 1
+ vscodex:
+ name: Build VS Code Codex extension
+ needs: preflight
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: |
+ aether-vscodex/package-lock.json
+ aether-vscodex/web/package-lock.json
+ aether-vscodex/vscode-extension/package-lock.json
+
+ - name: Install module test dependencies
+ working-directory: aether-vscodex
+ run: npm ci
+
+ - name: Build the embedded Web UI
+ working-directory: aether-vscodex/web
+ run: |
+ npm ci
+ npm run build
+
+ - name: Install extension dependencies
+ working-directory: aether-vscodex/vscode-extension
+ run: npm ci
+
+ - name: Check and compile the extension
+ working-directory: aether-vscodex/vscode-extension
+ run: |
+ npm run check
+ npm run build
+
+ - name: Run module tests
+ working-directory: aether-vscodex
+ run: npm test
+
+ - name: Run Web UI tests
+ working-directory: aether-vscodex/web
+ run: npm test
+
+ - name: Package VSIX
+ working-directory: aether-vscodex/vscode-extension
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="$(node -p "require('./package.json').version")"
+ npx --yes @vscode/vsce package --no-update-package-json --allow-missing-repository
+ source_vsix="codex-remote-collab-${version}.vsix"
+ test -f "${source_vsix}"
+ mv "${source_vsix}" "aether-vscodex-${version}.vsix"
+ unzip -l "aether-vscodex-${version}.vsix" | grep 'extension/node_modules/ws/index.js' >/dev/null
+
+ - name: Upload VSIX artifact
+ uses: actions/upload-artifact@v5
+ with:
+ name: aether-vscodex-vsix
+ path: aether-vscodex/vscode-extension/aether-vscodex-*.vsix
+ if-no-files-found: error
+ retention-days: 7
+
build:
name: Build ${{ matrix.name }}
needs: preflight
@@ -299,7 +372,7 @@ jobs:
github-release:
name: GitHub Release assets
- needs: [preflight, docker, package]
+ needs: [preflight, docker, package, vscodex]
if: needs.preflight.outputs.publish == 'true'
runs-on: ubuntu-latest
steps:
@@ -309,6 +382,12 @@ jobs:
name: release-assets
path: release-assets
+ - name: Download VSIX artifact
+ uses: actions/download-artifact@v5
+ with:
+ name: aether-vscodex-vsix
+ path: release-assets
+
- name: Delete stale draft releases for tag
env:
GH_TOKEN: ${{ github.token }}
@@ -340,3 +419,4 @@ jobs:
release-assets/*.tar.gz
release-assets/SHA256SUMS
release-assets/install.sh
+ release-assets/*.vsix
diff --git a/.gitignore b/.gitignore
index fab43389a..35ed543b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -248,3 +248,5 @@ src/_version.py
analysis/
new-api/
apps/aether-tunnel/aether-tunnel.toml
+# Generated by frontend/scripts/sync-vscodex.mjs.
+frontend/public/aether-vscodex/
diff --git a/Cargo.lock b/Cargo.lock
index c7b536424..f8d815e8c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -358,6 +358,7 @@ dependencies = [
"tikv-jemalloc-sys",
"tikv-jemallocator",
"tokio",
+ "tokio-tungstenite 0.28.0",
"tokio-util",
"tower",
"tower-http",
diff --git a/Dockerfile.app.local b/Dockerfile.app.local
index 6c0971e6d..3067d37e4 100644
--- a/Dockerfile.app.local
+++ b/Dockerfile.app.local
@@ -11,6 +11,15 @@ FROM ${NODE_BASE_IMAGE} AS frontend-builder
ARG AETHER_BUILD_VERSION
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION}
+WORKDIR /app/aether-vscodex/web
+COPY aether-vscodex/web/package*.json ./
+RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
+ npm config set registry https://registry.npmmirror.com && \
+ npm ci --no-audit --no-fund
+COPY aether-vscodex/public /app/aether-vscodex/public
+COPY aether-vscodex/web/ ./
+RUN npm run build
+
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
diff --git a/Dockerfile.app.release-local b/Dockerfile.app.release-local
index 8eddbd57a..169d79184 100644
--- a/Dockerfile.app.release-local
+++ b/Dockerfile.app.release-local
@@ -11,6 +11,15 @@ FROM ${NODE_BASE_IMAGE} AS frontend-builder
ARG AETHER_BUILD_VERSION
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
AETHER_VERSION=${AETHER_BUILD_VERSION}
+WORKDIR /app/aether-vscodex/web
+COPY aether-vscodex/web/package*.json ./
+RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
+ npm config set registry https://registry.npmmirror.com && \
+ npm ci --no-audit --no-fund
+COPY aether-vscodex/public /app/aether-vscodex/public
+COPY aether-vscodex/web/ ./
+RUN npm run build
+
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
diff --git a/README.md b/README.md
index e3ef2857b..bd430c520 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,12 @@ make dev
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
+## Codex 远程协同
+
+`aether-vscodex/` 是独立的 VS Code Codex 协同模块:同步模式跟随 VS Code 官方 Codex 面板当前会话且不另起进程;异步模式使用独立 app-server,让浏览器自行列出、恢复、新建和切换会话。两种模式都能从本机 URL 或 Aether 云端查看输出、发送消息和处理授权,模块内的 Vue 前端提供中英文界面。
+
+安装、云端配对和安全边界请参阅 [`aether-vscodex/README.md`](aether-vscodex/README.md)。
+
## Aether Tunnel (可选)
Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
diff --git a/aether-vscodex/.dockerignore b/aether-vscodex/.dockerignore
new file mode 100644
index 000000000..9739dbad4
--- /dev/null
+++ b/aether-vscodex/.dockerignore
@@ -0,0 +1,10 @@
+.git
+.github
+node_modules
+test
+fixtures
+vscode-extension
+*.vsix
+coverage
+data
+.DS_Store
diff --git a/aether-vscodex/.gitignore b/aether-vscodex/.gitignore
new file mode 100644
index 000000000..890f9f2b7
--- /dev/null
+++ b/aether-vscodex/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+vscode-extension/node_modules/
+vscode-extension/dist/
+data/
+coverage/
+*.vsix
+.DS_Store
+*.log
diff --git a/aether-vscodex/Dockerfile b/aether-vscodex/Dockerfile
new file mode 100644
index 000000000..5e8b74fc5
--- /dev/null
+++ b/aether-vscodex/Dockerfile
@@ -0,0 +1,26 @@
+FROM node:22-alpine
+
+ENV NODE_ENV=production \
+ HOST=0.0.0.0 \
+ PORT=8788 \
+ AETHER_VSCODEX_DATA_DIR=/var/lib/aether-vscodex
+
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+RUN npm ci --omit=dev && npm cache clean --force
+
+COPY cloud ./cloud
+COPY relay ./relay
+COPY public ./public
+
+RUN mkdir -p /var/lib/aether-vscodex && chown -R node:node /var/lib/aether-vscodex /app
+
+USER node
+
+EXPOSE 8788
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD node -e "fetch('http://127.0.0.1:8788/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
+
+CMD ["node", "cloud/server.js"]
diff --git a/aether-vscodex/README.md b/aether-vscodex/README.md
new file mode 100644
index 000000000..035e4309a
--- /dev/null
+++ b/aether-vscodex/README.md
@@ -0,0 +1,283 @@
+# aether-vscodex
+
+这个项目让浏览器从本机 URL 或 Aether 云端查看、输入并处理 Codex 会话,提供两种
+可随时切换的控制模式。默认的**同步模式**通过官方扩展使用的本机 IPC socket,严格
+跟随 VS Code Codex 面板当前会话,不启动另一个 `codex` 进程;**异步模式**由伴随扩展
+启动独立 app-server,网页可以自行列出、恢复、新建和切换会话。
+
+同一个伴随扩展可同时连接两个互不替代的通道:本机 loopback 控制台和部署在
+Aether 中的云端控制台。本机通道默认免密码且只能从本机访问;云端通道使用
+Aether 登录鉴权、一次性浏览器票据和独立设备凭据;父页面不会通过协议把 Aether JWT
+传给 iframe 或 Node sidecar。iframe 是随 Aether 一起发布的同源受信代码,不应被视为
+隔离不受信内容的安全边界。
+
+`vscode-extension/codex-remote-collab-0.4.0.vsix` 安装到 VS Code 后,会作为官方
+`openai.chatgpt` Codex 扩展的伴随扩展,并自动托管只监听本机的 relay。开发时仍可
+单独运行 `relay/server.js`。不要卸载或替换官方 Codex 扩展。
+
+## 工作方式
+
+```text
+官方 VS Code Codex 会话
+ │ 本机私有 IPC(只在 VS Code 所在机器上)
+ ▼
+ ┌── 本机 relay ── http://127.0.0.1:8787
+VS Code aether-vscodex 扩展 ────┤
+ └── Aether gateway ── 用户/设备隔离的云端 relay
+```
+
+控制模式与传输通道是两个独立维度:切换同步/异步不会重连本地或云端 relay。本机和
+Aether 控制页连接到同一台 VS Code 主机时,会看到同一个当前模式。
+
+| 控制模式 | 会话所有者 | 网页会话导航 |
+| --- | --- | --- |
+| 同步 | 官方 VS Code Codex 面板 | 禁止网页自行切换;自动跟随 VS Code |
+| 异步 | 扩展启动的独立 app-server | 可列出、恢复、新建和切换会话 |
+
+浏览器的 `operator` 可以发送任务、继续/中断当前 turn,并处理 Codex 的审批、
+用户输入和 MCP elicitation;`viewer` 只能查看事件和输出。远程浏览器不接触
+VS Code 的 SecretStorage,也不直接连接 IPC socket。
+
+## 前端结构
+
+Aether 页面使用仓库既有的 Vue 3、TypeScript、Vite 和 i18n。独立控制台也提供
+Vue/Vite 源码入口,但当前高保真的会话渲染与协议状态机作为兼容运行时保留,构建到
+`public/` 后同时供本机 URL 和 Aether 同源 iframe 使用。这样不需要一次性重写并丢失
+命令展开、滚动锚点、思考状态、Markdown、子代理、模型和权限菜单等已有行为。
+
+界面支持 `zh-CN` 与 `en-US`。Aether 的语言和深浅色主题会通过经过来源校验的
+`postMessage` 同步给 iframe;VS Code 命令与设置说明使用 `package.nls` 本地化。
+
+## Aether 云端部署
+
+云端模式由 Aether gateway 和独立 Node sidecar 组成。sidecar 只在 Compose 内网暴露
+8788,公网的 HTTP、配对交换和 WebSocket 都经 Aether gateway:
+
+```text
+GET /api/users/me/vscodex/devices
+POST /api/users/me/vscodex/pairings
+DELETE /api/users/me/vscodex/devices/:device_id
+POST /api/users/me/vscodex/ws-tickets
+POST /api/vscodex/pair
+WS /api/vscodex/ws
+```
+
+生成至少 32 字节的内部令牌,并按 Aether 的公开 HTTPS 地址设置变量:
+
+```sh
+export AETHER_VSCODEX_INTERNAL_TOKEN="$(openssl rand -base64 32)"
+export AETHER_VSCODEX_PUBLIC_WS_URL="wss://aether.example.com/api/vscodex/ws"
+export AETHER_VSCODEX_ALLOWED_ORIGINS="https://aether.example.com"
+
+docker compose \
+ -f docker-compose.yml \
+ -f docker-compose.local.yml \
+ -f aether-vscodex/docker-compose.aether.yml \
+ up -d --build
+```
+
+源码部署必须包含 `docker-compose.local.yml`,以保证 gateway、前端和 sidecar 来自同一份
+checkout。使用发布镜像时可以去掉该文件,但 `APP_IMAGE` 必须固定为包含相同
+`aether-vscodex` 协议版本的 Aether 镜像,不能把当前 sidecar 与旧的 `latest` gateway 混用。
+
+首次使用源码 Compose 前先构建控制台;正式 Aether 发布流程与 Dockerfile 已自动执行
+同一步骤:
+
+```sh
+npm --prefix aether-vscodex/web ci
+npm --prefix aether-vscodex/web run build
+```
+
+第一阶段 sidecar 是有状态单副本:设备凭据的 scrypt 哈希保存在
+`vscodex_data`,短期配对码、60 秒一次性浏览器票据和在线房间保存在内存。不要在未引入
+共享连接目录前横向扩容 sidecar。
+
+登录 Aether 后打开“Codex 远程控制”,生成一次性配对码。然后在 VS Code 命令面板执行
+**Codex Remote: Pair with Aether**,填写 Aether 地址和配对码。插件会把设备凭据写入
+VS Code SecretStorage,并同时保持本机控制台连接。
+
+## 快速开始
+
+前提:Node.js 20+;官方 `openai.chatgpt` VS Code 扩展已安装并登录;目标会话
+已经在 VS Code 的 Codex 面板中打开。VS Code 和 relay 必须以同一个操作系统用户
+运行,因为 IPC socket 是本机文件。
+
+1. 安装依赖并构建伴随扩展:
+
+ ```sh
+ npm --prefix vscode-extension install
+ npm --prefix vscode-extension run build
+ ```
+
+ 本机 `ws://` 地址会由扩展自动启动 relay;loopback 模式默认不需要 token,且
+ `host` 模式不会启动 `codex app-server`。
+
+2. 安装 `vscode-extension/codex-remote-collab-0.4.0.vsix`(或在扩展目录先
+ `npm run build` 再用 `npx --yes @vscode/vsce package` 打包),然后在 VS Code
+ 执行 **Developer: Reload Window**。
+
+3. 在 VS Code 设置中填写:
+
+ ```json
+ {
+ "codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
+ "codexRemoteCollab.controlMode": "sync",
+ "codexRemoteCollab.autoDiscoverThread": true,
+ "codexRemoteCollab.autoStart": true
+ }
+ ```
+
+4. 执行一次 **Developer: Reload Window** 后,扩展会自动找到最近的、仍由官方
+ VS Code Codex owner 持有的会话,并把已有输出同步到 relay;如果没有自动启动,
+ 无需手动启动或断开。右下角状态项只用于显示状态并打开 Web。需要精确指定会话时,执行
+ **Codex Remote: Set Existing Thread ID**;留空则恢复自动发现。
+ 官方 Codex 面板切换会话时,Web 默认会在新会话快照就绪后自动跟随;正在执行或等待
+ 授权的旧会话会先保持附着,结束后再安全切换。
+
+5. 浏览器打开 `http://127.0.0.1:8787`,页面会自动以本机 operator 身份连接,
+ 不需要输入密码。
+
+如果页面显示“等待 VS Code 主机连接”,先确认 relay 地址与扩展设置的端口完全一致,
+然后在 VS Code 执行一次 **Developer: Reload Window**。同步模式必须在官方 Codex
+面板已经打开至少一个会话后才能发现 owner;通常不需要手工填写
+`codexRemoteCollab.threadId`,留空会自动选择最近的可用会话。若之前填写过已经关闭的
+thread ID,清空该设置后再重载窗口。
+
+### 发布与下载插件
+
+正式发布时不需要用户在本地编译。仓库的 `.github/workflows/release.yml` 在推送
+`vX.Y.Z`、`vX.Y.Z-beta.N` 或 `vX.Y.Z-rc.N` 标签时,会在 GitHub Actions 中完成 Web
+前端构建、扩展编译和 VSIX 打包,并把
+`aether-vscodex-.vsix` 附加到对应的 GitHub Release。用户从 Release
+页面下载该 VSIX,在 VS Code 的扩展视图中选择“从 VSIX 安装...”即可;安装后执行一次
+**Developer: Reload Window**。
+
+手动运行该 workflow 时,VSIX 会作为 `aether-vscodex-vsix` Actions artifact 提供下载,
+但不会创建 GitHub Release。源码目录中的 VSIX 只用于本地开发验证,不是用户发布渠道。
+
+如果命令面板提示 `command 'codexRemoteCollab.start' not found`,通常是旧版
+VSIX 激活失败(旧包可能没有包含 `ws` 运行依赖)。请安装当前的
+`codex-remote-collab-0.4.0.vsix` 并使用 `--force` 覆盖旧版本,然后执行一次
+**Developer: Reload Window**:
+
+```sh
+code --install-extension vscode-extension/codex-remote-collab-0.4.0.vsix --force
+```
+
+也可以在 **Output → Codex Remote Collaboration** 中确认没有
+`Cannot find module 'ws'`;出现该错误时,说明扩展尚未成功激活。
+
+网页现在按官方 Codex Webview 的会话模型展示:历史和实时输出在中间消息流,用户、
+助手、reasoning、命令输出分别投影为对应的消息项;助手内容支持安全的 Markdown、
+代码块和复制操作,reasoning/命令活动可折叠。底部 composer 使用可编辑富文本区域,
+回车发送、Shift+Enter 换行;审批和用户输入会以内嵌 card 出现在会话流中,支持风险
+标记、输入控件、授权范围和明确的允许/拒绝动作。附着适配器会额外发送可选的
+`messages` 角色投影,旧版 host 没有该字段时网页仍回退到纯文本快照。
+
+页面打开后自动连接并在断线后重连,不再需要手动点击“连接”或“断开”。同步模式下
+会话列表、返回历史和新建入口会被禁用,所有输入都发送到 VS Code 当前会话。这里复刻的是从本机已安装
+官方 bundle 审计出的布局、状态和交互;官方 bundle 依赖 VS Code 私有 Webview API,
+不能安全地直接作为 iframe 嵌入浏览器。
+
+底部的“同步 / 异步”分段控件发送 `control/mode/set`。当前 turn 正在执行或存在待处理
+授权、用户输入时,主机拒绝切换;候选适配器启动失败时保留原模式和原会话。切入异步
+模式后,页面顶部会恢复会话历史、新建和选择入口;`session/list` 映射到
+`thread/list`,选择会话使用 `thread/resume` 并水合完整历史,新建会话使用
+`thread/start`。切回同步模式会关闭独立 app-server,并重新以 VS Code 面板为唯一
+会话导航来源。
+
+### 认证(可选)
+
+如果以后需要保护 relay,可显式开启认证;本机流程默认不需要这些变量:
+
+```sh
+CODEX_REMOTE_AUTH=required \
+CODEX_REMOTE_HOST_TOKEN='host-only-secret' \
+CODEX_REMOTE_TOKEN='browser-operator-secret' \
+CODEX_REMOTE_VIEW_TOKEN='browser-viewer-secret' \
+CODEX_REMOTE_MODE=host npm start
+```
+
+认证开启后,Host token 填在 VS Code 扩展中,Operator/Viewer token 填在浏览器中。
+
+## `spawn codex ENOENT` 是什么
+
+这个错误只表示某处正在尝试启动**独立**的 `codex app-server`,但 VS Code 图形
+进程的 `PATH` 找不到可执行文件。对于本项目默认的同步模式,不会调用
+`spawn codex`,因此不需要通过设置 `codexCommand` 来修复它。
+
+只有切换到异步模式(或仍使用旧版兼容设置)才需要独立可执行文件:
+
+```json
+"codexRemoteCollab.controlMode": "async"
+```
+
+扩展会优先解析 `codexRemoteCollab.codexCommand`,并可回退到官方 Codex 扩展内置的
+可执行文件;`codexRemoteCollab.codexArgs` 默认是 `["app-server", "--stdio"]`。
+旧 `mode=attach/spawn` 会分别迁移为 `sync/async`。
+
+## Relay 模式
+
+### `host`(推荐)
+
+relay 只负责认证、事件缓存和转发;VS Code 扩展通过私有 IPC 附着官方 Codex
+会话。必须先打开目标会话;本机 loopback 默认不需要 host token,只有显式开启认证时
+才把 host token 提供给扩展。
+
+### `embedded`(旧的独立进程模式)
+
+只有显式设置 `CODEX_REMOTE_MODE=embedded` 时,relay 才会启动自己的
+`codex app-server --stdio`,适合测试页面和公开 app-server 协议;它与 VS Code
+当前会话无关:
+
+```sh
+CODEX_REMOTE_MODE=embedded CODEX_CWD="$PWD" npm start
+```
+
+`CODEX_BIN` 可指定独立进程的可执行文件;`CODEX_ARGS_JSON` 可覆盖其参数。不要
+把这些设置误认为 attach 模式的必要配置。
+
+## HTTP API
+
+认证开启时,除 `/api/health` 外的 `/api/*` 都需要
+`Authorization: Bearer ` 或 `X-Codex-Token`;本机免认证
+模式下 loopback 请求直接作为 operator 处理。
+
+```text
+GET /api/health
+GET /api/state
+GET /api/events?fromSeq=0
+POST /api/command {"commandId":"...","method":"turn/start","params":{...}}
+POST /api/respond {"requestId":"...","result":{...}}
+```
+
+host 模式下,同步控制会拒绝 `thread/start` 和网页会话导航;异步控制会把它们转给
+独立 app-server。浏览器使用 `threadId` 发送 `turn/start`、`turn/steer` 或
+`turn/interrupt`。认证开启时写操作和
+响应请求必须使用 operator token;本机免认证模式下 loopback operator 可直接操作。
+
+## 私有协议和限制
+
+- IPC follower 协议是官方 VS Code 扩展的私有、带版本号实现,不是公开 API;官方
+ 扩展升级后可能需要同步适配。启用 `codexRemoteCollab.ipcStrictVersions`
+ 时,未知 stream 版本会让连接报错而不是猜测执行。
+- 自动发现只把本地 rollout 元数据当作候选,最终仍通过 IPC owner discovery
+ 验证;生产或多会话场景建议设置明确的 `threadId`。
+- relay 默认只监听 loopback,且 loopback 默认免认证;这意味着同一台机器上能访问
+ loopback 的本地进程都可能控制会话,不要把它反向代理或暴露到外部。如果开启 token
+ 认证,token 是 bearer secret。高风险授权默认被 host policy 拒绝,只有显式设置
+ `codexRemoteCollab.allowHighRiskApprovals=true` 才允许。
+- 输出会做常见 token/密码脱敏,但不能识别所有秘密;不要把凭据发送给 Codex。
+- 当前 UI 控制一个 host 会话,不提供多人同时编辑或文件同步。
+
+## 测试
+
+根目录测试使用假的 stdio app-server,不会向真实 Codex 发送任务:
+
+```sh
+npm test
+cd vscode-extension && npm run check && npm run build
+```
+
+要验证真实附着,只读地打开官方 VS Code 会话后启动 bridge;不要在验证脚本中
+调用 `turn/start`,除非你确实要向该会话发送任务。
diff --git a/aether-vscodex/cloud/server.js b/aether-vscodex/cloud/server.js
new file mode 100644
index 000000000..08724945e
--- /dev/null
+++ b/aether-vscodex/cloud/server.js
@@ -0,0 +1,727 @@
+"use strict";
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const http = require("node:http");
+const net = require("node:net");
+const path = require("node:path");
+const { URL } = require("node:url");
+const { WebSocket, WebSocketServer } = require("ws");
+
+const { CodexRelay } = require("../relay/server.js");
+
+const MAX_JSON_BYTES = 64 * 1024;
+const MAX_WS_BYTES = 16 * 1024 * 1024;
+const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
+const DEFAULT_TICKET_TTL_MS = 60 * 1000;
+const DEFAULT_ROOM_IDLE_MS = 30 * 60 * 1000;
+
+class DeviceStore {
+ constructor(filePath) {
+ this.filePath = filePath;
+ this.data = { version: 1, devices: [] };
+ this.load();
+ }
+
+ load() {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
+ if (parsed?.version !== 1 || !Array.isArray(parsed.devices)) throw new Error("unsupported device store format");
+ this.data = parsed;
+ } catch (error) {
+ if (error?.code !== "ENOENT") throw error;
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
+ this.persist();
+ }
+ }
+
+ list(userId, connectedDeviceIds = new Set()) {
+ return this.data.devices
+ .filter((device) => device.user_id === userId && !device.revoked_at)
+ .map((device) => publicDevice(device, connectedDeviceIds.has(device.id)));
+ }
+
+ create(userId, name) {
+ const id = crypto.randomUUID();
+ const secret = crypto.randomBytes(32).toString("base64url");
+ const salt = crypto.randomBytes(16).toString("base64url");
+ const now = new Date().toISOString();
+ const device = {
+ id,
+ user_id: userId,
+ name: normalizeName(name),
+ secret_salt: salt,
+ secret_hash: deriveSecret(secret, salt),
+ created_at: now,
+ last_seen_at: null,
+ revoked_at: null,
+ };
+ this.data.devices.push(device);
+ this.persist();
+ return { device: publicDevice(device, false), token: `avx1.${id}.${secret}` };
+ }
+
+ authenticate(token) {
+ const parsed = parseDeviceToken(token);
+ if (!parsed) return null;
+ const device = this.data.devices.find((candidate) => candidate.id === parsed.id && !candidate.revoked_at);
+ if (!device) return null;
+ const actual = Buffer.from(deriveSecret(parsed.secret, device.secret_salt), "base64url");
+ const expected = Buffer.from(device.secret_hash, "base64url");
+ if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) return null;
+ return device;
+ }
+
+ get(userId, deviceId) {
+ return this.data.devices.find((device) => device.user_id === userId && device.id === deviceId && !device.revoked_at) || null;
+ }
+
+ touch(deviceId) {
+ const device = this.data.devices.find((candidate) => candidate.id === deviceId && !candidate.revoked_at);
+ if (!device) return;
+ device.last_seen_at = new Date().toISOString();
+ this.persist();
+ }
+
+ revoke(userId, deviceId) {
+ const device = this.get(userId, deviceId);
+ if (!device) return false;
+ device.revoked_at = new Date().toISOString();
+ this.persist();
+ return true;
+ }
+
+ persist() {
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
+ const temporary = `${this.filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
+ fs.writeFileSync(temporary, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
+ fs.renameSync(temporary, this.filePath);
+ }
+}
+
+class EphemeralCredentials {
+ constructor(options = {}) {
+ this.pairingTtlMs = options.pairingTtlMs || DEFAULT_PAIRING_TTL_MS;
+ this.ticketTtlMs = options.ticketTtlMs || DEFAULT_TICKET_TTL_MS;
+ this.pairings = new Map();
+ this.tickets = new Map();
+ }
+
+ createPairing(userId, requestedName) {
+ const code = pairingCode();
+ const record = {
+ id: crypto.randomUUID(),
+ code,
+ user_id: userId,
+ requested_name: normalizeName(requestedName),
+ expires_at_ms: Date.now() + this.pairingTtlMs,
+ };
+ this.pairings.set(normalizePairingCode(code), record);
+ return record;
+ }
+
+ consumePairing(code) {
+ const key = normalizePairingCode(code);
+ const record = this.pairings.get(key);
+ this.pairings.delete(key);
+ if (!record || record.expires_at_ms <= Date.now()) return null;
+ return record;
+ }
+
+ createTicket(userId, deviceId) {
+ const ticket = `avt1.${crypto.randomBytes(32).toString("base64url")}`;
+ this.tickets.set(ticket, {
+ user_id: userId,
+ device_id: deviceId,
+ expires_at_ms: Date.now() + this.ticketTtlMs,
+ });
+ return ticket;
+ }
+
+ consumeTicket(ticket) {
+ const record = this.tickets.get(ticket);
+ this.tickets.delete(ticket);
+ if (!record || record.expires_at_ms <= Date.now()) return null;
+ return record;
+ }
+
+ cleanup() {
+ const now = Date.now();
+ for (const [key, record] of this.pairings) if (record.expires_at_ms <= now) this.pairings.delete(key);
+ for (const [key, record] of this.tickets) if (record.expires_at_ms <= now) this.tickets.delete(key);
+ }
+}
+
+class RoomManager {
+ constructor(options = {}) {
+ this.rooms = new Map();
+ this.pendingRooms = new Map();
+ this.revokedRoomKeys = new Set();
+ this.idleMs = options.idleMs || DEFAULT_ROOM_IDLE_MS;
+ }
+
+ key(userId, deviceId) {
+ return `${encodeURIComponent(userId)}:${deviceId}`;
+ }
+
+ async get(userId, deviceId) {
+ const key = this.key(userId, deviceId);
+ if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
+ let room = this.rooms.get(key);
+ if (!room && this.pendingRooms.has(key)) room = await this.pendingRooms.get(key);
+ if (!room) {
+ const creating = this.createRoom(key, userId, deviceId);
+ this.pendingRooms.set(key, creating);
+ try {
+ room = await creating;
+ } finally {
+ this.pendingRooms.delete(key);
+ }
+ }
+ if (this.revokedRoomKeys.has(key)) throw httpError(401, "device revoked");
+ room.lastActiveMs = Date.now();
+ return room;
+ }
+
+ async createRoom(key, userId, deviceId) {
+ const hostToken = randomToken();
+ const operatorToken = randomToken();
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ authRequired: true,
+ hostToken,
+ operatorToken,
+ viewerToken: randomToken(),
+ });
+ await relay.start();
+ if (this.revokedRoomKeys.has(key)) {
+ await relay.stop().catch(() => undefined);
+ throw httpError(401, "device revoked");
+ }
+ const address = relay.address();
+ const room = {
+ key,
+ userId,
+ deviceId,
+ relay,
+ hostToken,
+ operatorToken,
+ baseUrl: `ws://127.0.0.1:${address.port}`,
+ connections: 0,
+ lastActiveMs: Date.now(),
+ };
+ this.rooms.set(key, room);
+ return room;
+ }
+
+ connectedDeviceIds(userId) {
+ return new Set([...this.rooms.values()]
+ .filter((room) => room.userId === userId && room.relay.state.hostConnected)
+ .map((room) => room.deviceId));
+ }
+
+ retain(room) {
+ room.connections += 1;
+ room.lastActiveMs = Date.now();
+ }
+
+ release(room) {
+ room.connections = Math.max(0, room.connections - 1);
+ room.lastActiveMs = Date.now();
+ }
+
+ async cleanup() {
+ const now = Date.now();
+ for (const [key, room] of this.rooms) {
+ if (room.connections > 0 || now - room.lastActiveMs < this.idleMs) continue;
+ this.rooms.delete(key);
+ await room.relay.stop();
+ }
+ }
+
+ async revoke(userId, deviceId) {
+ const key = this.key(userId, deviceId);
+ this.revokedRoomKeys.add(key);
+ const pending = this.pendingRooms.get(key);
+ if (pending) await pending.catch(() => undefined);
+ const room = this.rooms.get(key);
+ if (!room) return;
+ this.rooms.delete(key);
+ await room.relay.stop();
+ }
+
+ async stop() {
+ await Promise.allSettled([...this.pendingRooms.values()]);
+ this.pendingRooms.clear();
+ const rooms = [...this.rooms.values()];
+ this.rooms.clear();
+ this.revokedRoomKeys.clear();
+ await Promise.allSettled(rooms.map((room) => room.relay.stop()));
+ }
+}
+
+class AetherVscodexCloudServer {
+ constructor(options = {}) {
+ this.host = options.host || process.env.HOST || "127.0.0.1";
+ this.port = parsePort(options.port ?? process.env.PORT, 8788);
+ this.internalToken = options.internalToken || process.env.AETHER_VSCODEX_INTERNAL_TOKEN || "";
+ this.publicWsUrl = options.publicWsUrl || process.env.AETHER_VSCODEX_PUBLIC_WS_URL || "";
+ this.allowedOrigins = normalizeOrigins(options.allowedOrigins ?? process.env.AETHER_VSCODEX_ALLOWED_ORIGINS);
+ const dataDir = options.dataDir || process.env.AETHER_VSCODEX_DATA_DIR || path.join(process.cwd(), "data");
+ this.store = options.store || new DeviceStore(path.join(dataDir, "devices.json"));
+ this.credentials = options.credentials || new EphemeralCredentials(options);
+ this.rooms = options.rooms || new RoomManager(options);
+ this.exchangeAttempts = new Map();
+ this.httpServer = null;
+ this.wsServer = null;
+ this.cleanupTimer = null;
+ }
+
+ async start() {
+ if (!this.internalToken) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN is required");
+ if (Buffer.byteLength(this.internalToken, "utf8") < 24) throw new Error("AETHER_VSCODEX_INTERNAL_TOKEN must contain at least 24 bytes");
+ if (!this.publicWsUrl) throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL is required");
+ validatePublicWsUrl(this.publicWsUrl);
+ if (!isLoopbackHost(this.host) && this.allowedOrigins.size === 0) {
+ throw new Error("AETHER_VSCODEX_ALLOWED_ORIGINS is required when binding outside loopback");
+ }
+ this.httpServer = http.createServer((request, response) => {
+ void this.handleHttp(request, response).catch((error) => {
+ jsonResponse(response, error.statusCode || 500, { error: error.expose ? error.message : "internal server error" });
+ });
+ });
+ this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_BYTES });
+ this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head));
+ this.cleanupTimer = setInterval(() => {
+ this.credentials.cleanup();
+ this.cleanupExchangeAttempts();
+ void this.rooms.cleanup();
+ }, 30_000);
+ this.cleanupTimer.unref();
+ await new Promise((resolve, reject) => {
+ const onError = (error) => reject(error);
+ this.httpServer.once("error", onError);
+ this.httpServer.listen(this.port, this.host, () => {
+ this.httpServer.off("error", onError);
+ resolve();
+ });
+ });
+ return this.address();
+ }
+
+ address() {
+ const address = this.httpServer.address();
+ if (!address || typeof address === "string") return { host: this.host, port: this.port };
+ return { host: address.address, port: address.port };
+ }
+
+ async stop() {
+ if (this.cleanupTimer) clearInterval(this.cleanupTimer);
+ this.cleanupTimer = null;
+ if (this.wsServer) {
+ for (const client of this.wsServer.clients) client.close(1001, "server shutting down");
+ await new Promise((resolve) => this.wsServer.close(() => resolve()));
+ }
+ if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve()));
+ this.wsServer = null;
+ this.httpServer = null;
+ await this.rooms.stop();
+ }
+
+ async handleHttp(request, response) {
+ const requestUrl = new URL(request.url || "/", "http://sidecar.local");
+ if (request.method === "GET" && requestUrl.pathname === "/healthz") {
+ jsonResponse(response, 200, { ok: true, service: "aether-vscodex", mode: "single-replica" });
+ return;
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/v1/pairings/exchange") {
+ this.enforceExchangeRate(request);
+ const body = await readJson(request);
+ const pairing = this.credentials.consumePairing(body.code);
+ if (!pairing) throw httpError(400, "invalid or expired pairing code");
+ const created = this.store.create(pairing.user_id, body.name || pairing.requested_name);
+ jsonResponse(response, 201, {
+ device_id: created.device.id,
+ device_name: created.device.name,
+ device_token: created.token,
+ ws_url: this.publicWsUrl,
+ });
+ return;
+ }
+
+ const match = requestUrl.pathname.match(/^\/internal\/v1\/users\/([^/]+)\/(devices|pairings|ws-tickets)(?:\/([^/]+))?$/);
+ if (!match) {
+ jsonResponse(response, 404, { error: "not found" });
+ return;
+ }
+ this.requireInternalAuth(request);
+ const userId = decodeURIComponent(match[1]);
+ const resource = match[2];
+ const resourceId = match[3] ? decodeURIComponent(match[3]) : null;
+ if (!userId || userId.length > 256) throw httpError(400, "invalid user id");
+
+ if (request.method === "GET" && resource === "devices" && !resourceId) {
+ jsonResponse(response, 200, { devices: this.store.list(userId, this.rooms.connectedDeviceIds(userId)) });
+ return;
+ }
+ if (request.method === "POST" && resource === "pairings" && !resourceId) {
+ const body = await readJson(request);
+ const pairing = this.credentials.createPairing(userId, body.name);
+ jsonResponse(response, 201, {
+ pairing_id: pairing.id,
+ code: pairing.code,
+ expires_at: new Date(pairing.expires_at_ms).toISOString(),
+ });
+ return;
+ }
+ if (request.method === "DELETE" && resource === "devices" && resourceId) {
+ if (!this.store.revoke(userId, resourceId)) throw httpError(404, "device not found");
+ await this.rooms.revoke(userId, resourceId);
+ response.writeHead(204, { "Cache-Control": "no-store" });
+ response.end();
+ return;
+ }
+ if (request.method === "POST" && resource === "ws-tickets" && !resourceId) {
+ const body = await readJson(request);
+ const deviceId = typeof body.device_id === "string" ? body.device_id : "";
+ if (!deviceId || !this.store.get(userId, deviceId)) throw httpError(404, "device not found");
+ jsonResponse(response, 201, {
+ ticket: this.credentials.createTicket(userId, deviceId),
+ ws_url: "/api/vscodex/ws",
+ expires_in: Math.floor(this.credentials.ticketTtlMs / 1000),
+ });
+ return;
+ }
+ jsonResponse(response, 405, { error: "method not allowed" }, { Allow: allowedMethod(resource, resourceId) });
+ }
+
+ requireInternalAuth(request) {
+ if (!this.hasInternalAuth(request)) throw httpError(401, "unauthorized");
+ }
+
+ hasInternalAuth(request) {
+ const authorization = String(request.headers.authorization || "");
+ const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
+ return secureEqual(token, this.internalToken);
+ }
+
+ enforceExchangeRate(request) {
+ const address = this.exchangeRateAddress(request);
+ const now = Date.now();
+ const attempts = (this.exchangeAttempts.get(address) || []).filter((time) => now - time < 60_000);
+ if (attempts.length >= 10) throw httpError(429, "too many pairing attempts");
+ attempts.push(now);
+ this.exchangeAttempts.set(address, attempts);
+ }
+
+ exchangeRateAddress(request) {
+ if (this.hasInternalAuth(request)) {
+ const forwardedAddress = singleHeaderValue(request, "x-aether-client-ip")?.trim();
+ if (forwardedAddress && net.isIP(forwardedAddress)) return forwardedAddress;
+ }
+ return request.socket.remoteAddress || "unknown";
+ }
+
+ cleanupExchangeAttempts() {
+ const now = Date.now();
+ for (const [address, attempts] of this.exchangeAttempts) {
+ const active = attempts.filter((time) => now - time < 60_000);
+ if (active.length) this.exchangeAttempts.set(address, active);
+ else this.exchangeAttempts.delete(address);
+ }
+ }
+
+ handleUpgrade(request, socket, head) {
+ const requestUrl = new URL(request.url || "/", "http://sidecar.local");
+ if (requestUrl.pathname !== "/api/vscodex/ws" && requestUrl.pathname !== "/v1/connect") {
+ rejectUpgrade(socket, 404, "Not Found");
+ return;
+ }
+ const origin = request.headers.origin;
+ if (origin && this.allowedOrigins.size > 0 && !this.allowedOrigins.has(normalizeOrigin(origin))) {
+ rejectUpgrade(socket, 403, "Forbidden");
+ return;
+ }
+ this.wsServer.handleUpgrade(request, socket, head, (webSocket) => {
+ this.wsServer.emit("connection", webSocket, request);
+ this.handleWebSocket(webSocket, request);
+ });
+ }
+
+ handleWebSocket(socket, request) {
+ let hello = null;
+ let token = "";
+ let upstream = null;
+ let authenticating = false;
+ let room = null;
+ const queued = [];
+ const authTimer = setTimeout(() => socket.close(1008, "authentication required"), 10_000);
+ authTimer.unref();
+
+ const connectUpstream = async () => {
+ if (authenticating || upstream || !hello || !token) return;
+ authenticating = true;
+ let identity;
+ let upstreamToken;
+ if (hello.clientType === "host") {
+ const device = this.store.authenticate(token);
+ if (!device) throw httpError(401, "invalid device credential");
+ identity = { userId: device.user_id, deviceId: device.id };
+ room = await this.rooms.get(identity.userId, identity.deviceId);
+ upstreamToken = room.hostToken;
+ this.store.touch(device.id);
+ } else {
+ const ticket = this.credentials.consumeTicket(token);
+ if (!ticket || !this.store.get(ticket.user_id, ticket.device_id)) throw httpError(401, "invalid or expired browser ticket");
+ identity = { userId: ticket.user_id, deviceId: ticket.device_id };
+ room = await this.rooms.get(identity.userId, identity.deviceId);
+ upstreamToken = room.operatorToken;
+ }
+ this.rooms.retain(room);
+ upstream = new WebSocket(`${room.baseUrl}${hello.clientType === "host" ? "/v1/connect" : "/ws"}`, {
+ maxPayload: MAX_WS_BYTES,
+ });
+ upstream.once("open", () => {
+ if (socket.readyState !== WebSocket.OPEN) {
+ upstream.close();
+ return;
+ }
+ upstream.send(JSON.stringify(hello));
+ upstream.send(JSON.stringify(hello.clientType === "host"
+ ? { v: 1, kind: "auth", accessToken: upstreamToken }
+ : { type: "auth", token: upstreamToken }));
+ for (const frame of queued.splice(0)) upstream.send(frame);
+ });
+ upstream.on("message", (data, isBinary) => {
+ if (socket.readyState === WebSocket.OPEN) socket.send(data, { binary: isBinary });
+ });
+ upstream.on("close", (code, reason) => {
+ if (socket.readyState === WebSocket.OPEN) socket.close(validCloseCode(code) ? code : 1011, reason.toString().slice(0, 120) || "relay closed");
+ });
+ upstream.on("error", () => {
+ if (socket.readyState === WebSocket.OPEN) socket.close(1011, "relay unavailable");
+ });
+ clearTimeout(authTimer);
+ };
+
+ socket.on("message", (data, isBinary) => {
+ if (isBinary) {
+ socket.close(1003, "JSON text frames only");
+ return;
+ }
+ if (upstream) {
+ const text = data.toString("utf8");
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(text);
+ else queued.push(text);
+ return;
+ }
+ let message;
+ try {
+ message = JSON.parse(data.toString("utf8"));
+ } catch {
+ socket.close(1007, "invalid JSON");
+ return;
+ }
+ if (message?.kind === "hello") {
+ if (Number(message.protocol || 1) !== 1) {
+ socket.close(1002, "unsupported protocol");
+ return;
+ }
+ hello = {
+ v: 1,
+ kind: "hello",
+ clientType: message.clientType === "host" ? "host" : "web",
+ protocol: 1,
+ ...(typeof message.sessionId === "string" ? { sessionId: message.sessionId } : {}),
+ ...(Number.isFinite(Number(message.lastSeq)) ? { lastSeq: Number(message.lastSeq) } : {}),
+ };
+ } else if (message?.kind === "auth" || message?.type === "auth") {
+ token = typeof message.accessToken === "string" ? message.accessToken : typeof message.token === "string" ? message.token : "";
+ } else {
+ socket.close(1002, "hello and auth required");
+ return;
+ }
+ void connectUpstream().catch(() => socket.close(1008, "authentication failed"));
+ });
+ socket.on("close", () => {
+ clearTimeout(authTimer);
+ if (upstream && upstream.readyState < WebSocket.CLOSING) upstream.close();
+ if (room) this.rooms.release(room);
+ });
+ socket.on("error", () => {});
+ }
+}
+
+function parseDeviceToken(token) {
+ const match = /^avx1\.([0-9a-f-]{36})\.([A-Za-z0-9_-]{32,})$/.exec(String(token || ""));
+ return match ? { id: match[1], secret: match[2] } : null;
+}
+
+function deriveSecret(secret, salt) {
+ return crypto.scryptSync(secret, Buffer.from(salt, "base64url"), 32).toString("base64url");
+}
+
+function publicDevice(device, connected) {
+ return {
+ id: device.id,
+ name: device.name,
+ connected,
+ created_at: device.created_at,
+ last_seen_at: device.last_seen_at,
+ };
+}
+
+function normalizeName(value) {
+ const name = typeof value === "string" ? value.trim().replace(/\s+/g, " ").slice(0, 80) : "";
+ return name || "VS Code";
+}
+
+function pairingCode() {
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ const bytes = crypto.randomBytes(8);
+ let result = "";
+ for (let index = 0; index < 8; index += 1) result += alphabet[bytes[index] % alphabet.length];
+ return `${result.slice(0, 4)}-${result.slice(4)}`;
+}
+
+function normalizePairingCode(value) {
+ return String(value || "").toUpperCase().replace(/[^A-Z2-9]/g, "");
+}
+
+function randomToken() {
+ return crypto.randomBytes(32).toString("base64url");
+}
+
+function secureEqual(left, right) {
+ const a = Buffer.from(String(left || ""));
+ const b = Buffer.from(String(right || ""));
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+function singleHeaderValue(request, name) {
+ const distinctValues = request.headersDistinct?.[name];
+ if (Array.isArray(distinctValues)) return distinctValues.length === 1 ? distinctValues[0] : null;
+ const value = request.headers[name];
+ return typeof value === "string" ? value : null;
+}
+
+function parsePort(value, fallback) {
+ const parsed = Number(value ?? fallback);
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("invalid port");
+ return parsed;
+}
+
+function normalizeOrigins(value) {
+ const values = Array.isArray(value) ? value : String(value || "").split(",");
+ return new Set(values.map(normalizeOrigin).filter(Boolean));
+}
+
+function normalizeOrigin(value) {
+ try {
+ return new URL(String(value).trim()).origin.toLowerCase();
+ } catch {
+ return "";
+ }
+}
+
+function validatePublicWsUrl(value) {
+ let url;
+ try {
+ url = new URL(value);
+ } catch {
+ throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must be an absolute WebSocket URL");
+ }
+ if (url.protocol !== "wss:" && !(url.protocol === "ws:" && isLoopbackHost(url.hostname))) {
+ throw new Error("AETHER_VSCODEX_PUBLIC_WS_URL must use wss:// outside loopback");
+ }
+}
+
+function isLoopbackHost(value) {
+ const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase();
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
+}
+
+function validCloseCode(code) {
+ return code === 1000 || (code >= 1001 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999);
+}
+
+function readJson(request) {
+ return new Promise((resolve, reject) => {
+ let size = 0;
+ const chunks = [];
+ request.on("data", (chunk) => {
+ size += chunk.length;
+ if (size > MAX_JSON_BYTES) {
+ reject(httpError(413, "request body too large"));
+ request.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ request.on("end", () => {
+ try {
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
+ resolve(value);
+ } catch {
+ reject(httpError(400, "invalid JSON body"));
+ }
+ });
+ request.on("error", reject);
+ });
+}
+
+function jsonResponse(response, statusCode, body, extraHeaders = {}) {
+ if (response.headersSent) return;
+ const payload = Buffer.from(JSON.stringify(body));
+ response.writeHead(statusCode, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Length": payload.length,
+ "Cache-Control": "no-store",
+ ...extraHeaders,
+ });
+ response.end(payload);
+}
+
+function rejectUpgrade(socket, status, reason) {
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
+ socket.destroy();
+}
+
+function httpError(statusCode, message) {
+ return Object.assign(new Error(message), { statusCode, expose: statusCode < 500 });
+}
+
+function allowedMethod(resource, resourceId) {
+ if (resource === "devices" && resourceId) return "DELETE";
+ if (resource === "devices") return "GET";
+ return "POST";
+}
+
+async function main() {
+ const server = new AetherVscodexCloudServer();
+ const address = await server.start();
+ process.stdout.write(`Aether VS Codex sidecar listening on ${address.host}:${address.port}\n`);
+ const shutdown = async () => {
+ await server.stop();
+ process.exit(0);
+ };
+ process.once("SIGINT", shutdown);
+ process.once("SIGTERM", shutdown);
+}
+
+if (require.main === module) {
+ main().catch((error) => {
+ process.stderr.write(`${error.stack || error}\n`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = {
+ AetherVscodexCloudServer,
+ DeviceStore,
+ EphemeralCredentials,
+ RoomManager,
+};
diff --git a/aether-vscodex/docker-compose.aether.yml b/aether-vscodex/docker-compose.aether.yml
new file mode 100644
index 000000000..2ebffc44b
--- /dev/null
+++ b/aether-vscodex/docker-compose.aether.yml
@@ -0,0 +1,37 @@
+services:
+ app:
+ environment:
+ AETHER_VSCODEX_ENABLED: "true"
+ AETHER_VSCODEX_INTERNAL_URL: http://vscodex:8788
+ AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
+ AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
+ depends_on:
+ vscodex:
+ condition: service_healthy
+ volumes:
+ - ./aether-vscodex/web/dist:/opt/aether/releases/image/frontend/aether-vscodex:ro
+
+ vscodex:
+ build:
+ context: ./aether-vscodex
+ image: ${AETHER_VSCODEX_IMAGE:-aether-vscodex:local}
+ environment:
+ HOST: 0.0.0.0
+ PORT: 8788
+ AETHER_VSCODEX_INTERNAL_TOKEN: ${AETHER_VSCODEX_INTERNAL_TOKEN:?set AETHER_VSCODEX_INTERNAL_TOKEN}
+ AETHER_VSCODEX_PUBLIC_WS_URL: ${AETHER_VSCODEX_PUBLIC_WS_URL:?set AETHER_VSCODEX_PUBLIC_WS_URL}
+ AETHER_VSCODEX_ALLOWED_ORIGINS: ${AETHER_VSCODEX_ALLOWED_ORIGINS:?set AETHER_VSCODEX_ALLOWED_ORIGINS}
+ AETHER_VSCODEX_DATA_DIR: /var/lib/aether-vscodex
+ expose:
+ - "8788"
+ volumes:
+ - vscodex_data:/var/lib/aether-vscodex
+ logging:
+ driver: local
+ options:
+ max-size: "50m"
+ max-file: "3"
+ restart: unless-stopped
+
+volumes:
+ vscodex_data:
diff --git a/aether-vscodex/docs/cloud-security.md b/aether-vscodex/docs/cloud-security.md
new file mode 100644
index 000000000..3608afe89
--- /dev/null
+++ b/aether-vscodex/docs/cloud-security.md
@@ -0,0 +1,20 @@
+# Cloud security model
+
+## Trust boundaries
+
+- Aether authenticates browser HTTP requests and resolves the user ID. The client never supplies a trusted user ID.
+- The Node sidecar never receives an Aether access token or JWT signing key.
+- A VS Code installation receives one revocable device credential. Only its scrypt hash is persisted.
+- An iframe receives a random, one-time WebSocket ticket with a 60-second lifetime. Tickets are sent in an auth frame, never in a URL.
+- The embedded UI is trusted, same-origin Aether code. `allow-same-origin` is required by the current integration, so the iframe is not a sandbox boundary for untrusted content even though the parent does not post its JWT into the frame.
+- Relay state is isolated by `(user_id, device_id)`. A browser ticket and host credential must resolve to the same room.
+
+## Network boundary
+
+Run the sidecar on the private Compose network. Do not publish port 8788. Aether gateway is the only public HTTP and WebSocket entry point and authenticates internal API calls with `AETHER_VSCODEX_INTERNAL_TOKEN`.
+
+`AETHER_VSCODEX_ALLOWED_ORIGINS` must contain the exact public Aether origin when the sidecar binds outside loopback. Public deployments must use HTTPS/WSS.
+
+## Current scaling limit
+
+The first release intentionally runs one sidecar replica. Pairing codes, browser tickets, and the live connection directory are process-local. Before adding replicas, move those records to a shared atomic store and add sticky or distributed WebSocket room routing.
diff --git a/aether-vscodex/fixtures/fake-app-server.cjs b/aether-vscodex/fixtures/fake-app-server.cjs
new file mode 100644
index 000000000..2001db255
--- /dev/null
+++ b/aether-vscodex/fixtures/fake-app-server.cjs
@@ -0,0 +1,59 @@
+"use strict";
+
+const readline = require("node:readline");
+
+let threadNumber = 0;
+let turnNumber = 0;
+let activeThread = null;
+let activeTurn = null;
+
+function send(message) {
+ process.stdout.write(`${JSON.stringify(message)}\n`);
+}
+
+const input = readline.createInterface({ input: process.stdin });
+input.on("line", (line) => {
+ let request;
+ try { request = JSON.parse(line); } catch { return; }
+ if (request.method === "initialize") {
+ send({ id: request.id, result: { userAgent: "fake", codexHome: "/tmp/codex" } });
+ send({ method: "remoteControl/status/changed", params: { status: "disabled" } });
+ return;
+ }
+ if (request.method === "thread/start") {
+ activeThread = `thread-${++threadNumber}`;
+ send({ id: request.id, result: { thread: { id: activeThread }, cwd: request.params?.cwd || "/tmp" } });
+ send({ method: "thread/started", params: { thread: { id: activeThread } } });
+ return;
+ }
+ if (request.method === "turn/start") {
+ activeTurn = `turn-${++turnNumber}`;
+ send({ id: request.id, result: { turn: { id: activeTurn } } });
+ send({ method: "turn/started", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
+ const text = request.params.input?.[0]?.text || "";
+ send({ method: "item/agentMessage/delta", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-1", delta: `echo: ${text}` } });
+ if (text.includes("approve")) {
+ send({ id: 9001, method: "item/commandExecution/requestApproval", params: { threadId: request.params.threadId, turnId: activeTurn, itemId: "item-2", command: "echo approval" } });
+ } else {
+ send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: activeTurn } } });
+ activeTurn = null;
+ }
+ return;
+ }
+ if (request.method === "turn/steer") {
+ send({ id: request.id, result: { turn: { id: activeTurn } } });
+ send({ method: "item/agentMessage/delta", params: { delta: `steered: ${request.params.input?.[0]?.text || ""}` } });
+ return;
+ }
+ if (request.method === "turn/interrupt") {
+ send({ id: request.id, result: {} });
+ send({ method: "turn/completed", params: { threadId: request.params.threadId, turn: { id: request.params.turnId } } });
+ activeTurn = null;
+ return;
+ }
+ if (request.id === 9001 && (request.result || request.error)) {
+ send({ method: "item/agentMessage/delta", params: { delta: `approval response: ${JSON.stringify(request.result || request.error)}` } });
+ send({ method: "turn/completed", params: { threadId: activeThread, turn: { id: activeTurn } } });
+ activeTurn = null;
+ }
+});
diff --git a/aether-vscodex/package-lock.json b/aether-vscodex/package-lock.json
new file mode 100644
index 000000000..97286de97
--- /dev/null
+++ b/aether-vscodex/package-lock.json
@@ -0,0 +1,39 @@
+{
+ "name": "aether-vscodex",
+ "version": "0.4.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "aether-vscodex",
+ "version": "0.4.0",
+ "dependencies": {
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/aether-vscodex/package.json b/aether-vscodex/package.json
new file mode 100644
index 000000000..831fe6aa0
--- /dev/null
+++ b/aether-vscodex/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "aether-vscodex",
+ "version": "0.4.0",
+ "private": true,
+ "description": "Synchronous VS Code Codex mirroring and asynchronous Codex control for local Web and Aether",
+ "type": "commonjs",
+ "main": "relay/server.js",
+ "scripts": {
+ "start": "node relay/server.js",
+ "start:cloud": "node cloud/server.js",
+ "build:web": "npm --prefix web run build",
+ "build:extension": "npm --prefix vscode-extension run build",
+ "build": "npm run build:web && npm run build:extension",
+ "test": "node --test test/*.test.js",
+ "test:web": "npm --prefix web test"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "dependencies": {
+ "ws": "^8.18.3"
+ }
+}
diff --git a/aether-vscodex/public/app.js b/aether-vscodex/public/app.js
new file mode 100644
index 000000000..e6947d26b
--- /dev/null
+++ b/aether-vscodex/public/app.js
@@ -0,0 +1,8199 @@
+(function () {
+ "use strict";
+
+ const $ = (id) => document.getElementById(id);
+ const embedBridge = window.AetherVscodexEmbed;
+ const embeddedInAether = Boolean(embedBridge?.active);
+ const i18n = window.VscodexI18n;
+ const t = (value) => i18n?.t ? i18n.t(value) : String(value ?? "");
+ const uiLocale = () => i18n?.locale?.() || "zh-CN";
+ // Translate renderer-owned labels while leaving values supplied by the host
+ // (conversation titles, file paths, commands, and message text) untouched.
+ const uiText = (zh, en) => uiLocale() === "en-US" ? en : zh;
+ const uiWithRaw = (zhPrefix, enPrefix, raw, zhSuffix = "", enSuffix = "") =>
+ `${uiText(zhPrefix, enPrefix)}${String(raw ?? "")}${uiText(zhSuffix, enSuffix)}`;
+ const appStatusLabel = (value) => {
+ const normalized = String(value || "").trim().toLowerCase();
+ const labels = {
+ ready: "已连接",
+ online: "已连接",
+ offline: "VS Code 主机未连接",
+ waiting_for_host: "等待 VS Code 主机连接",
+ app_not_ready: "等待 VS Code 主机连接",
+ starting: "正在连接",
+ connecting: "正在连接",
+ stopped: "已停止",
+ };
+ return t(labels[normalized] || value || "未知");
+ };
+ const state = {
+ ws: null,
+ token: "",
+ role: null,
+ appReady: false,
+ lastSeq: 0,
+ // A control snapshot is authoritative for every event up to this
+ // sequence. Replayed notifications from the subscribe handshake must not
+ // resurrect an already-finished turn or duplicate its transcript.
+ lastSnapshotSeq: 0,
+ awaitingSnapshot: false,
+ threadId: "",
+ turnId: "",
+ attachMode: false,
+ authRequired: null,
+ outputSynced: false,
+ structuredMessages: [],
+ requests: new Map(),
+ responding: new Set(),
+ commandResults: new Set(),
+ reconnectTimer: null,
+ embedTicket: "",
+ embedWsUrl: "",
+ embedDeviceId: "",
+ embedTicketRequested: false,
+ embedStopped: false,
+ activeAssistantBody: null,
+ activeAssistantStream: null,
+ activeAssistantText: "",
+ pendingUserText: "",
+ retiredTurnIds: new Set(),
+ syncedThreadId: null,
+ snapshotNoticeShown: false,
+ // Live work items are rendered as updateable transcript entries. The
+ // adapter may emit item lifecycle notifications or only output chunks;
+ // keeping a small client-side index lets both forms converge on one row.
+ activities: new Map(),
+ commandDisclosure: new Map(),
+ activitySequence: 0,
+ activityTimer: null,
+ activeAssistantActivityKey: null,
+ turnStartedAt: null,
+ // The official worked-for row measures from the first work item until
+ // the final assistant response starts. Keep this separate from the
+ // overall turn clock because the latter also includes queue/approval time.
+ turnWorkStartedAt: null,
+ finalAssistantStartedAt: null,
+ turnStatus: "idle",
+ lastTurnDurationMs: null,
+ lastWorkedDurationMs: null,
+ workedDurationMs: null,
+ currentActivity: "idle",
+ currentActivityStartedAt: null,
+ currentActivityDurationMs: null,
+ currentActivityTurnId: "",
+ currentModel: "",
+ // Empty means the host has not reported an effort yet; null is an
+ // authoritative "use the model default" value and must not be serialized
+ // as medium on the next turn.
+ currentEffort: "",
+ sandboxPolicy: "workspace-write",
+ approvalPolicy: "on-request",
+ tokenUsage: null,
+ availableModels: [],
+ subagents: [],
+ controlMode: "sync",
+ modeEpoch: -1,
+ capabilities: {
+ followsVscodeRoute: true,
+ sessionList: false,
+ sessionSelect: false,
+ sessionCreate: false,
+ threadSettings: false,
+ },
+ modeSnapshotReady: false,
+ modeSwitching: false,
+ modeCommandId: "",
+ requestedControlMode: "",
+ modeRequestEpoch: -1,
+ sessions: [],
+ sessionPickerOpen: false,
+ sessionSearch: "",
+ sessionFocusedId: "",
+ sessionListLoading: false,
+ sessionListError: "",
+ sessionListCommandId: "",
+ sessionSelectCommandId: "",
+ newSessionCommandId: "",
+ sessionSelectedThreadId: "",
+ sessionSwitching: false,
+ // Keep the previous view/title visible until the host confirms the target
+ // with an authoritative session snapshot. A target can be listed as
+ // attachable and still time out during owner hand-off; dropping the old
+ // DOM at `session.switching` would leave the browser blank in that case.
+ sessionSwitchContext: null,
+ modelUpdatePending: false,
+ modelAdvancedOpen: false,
+ // The official composer keeps the background-agent disclosure closed on
+ // first render; the @ hint appears only after the reader expands it.
+ subagentsCollapsed: true,
+ subagentsExpanded: { active: false, done: false },
+ lastRenderedDateKey: "",
+ lastRenderedTimestamp: null,
+ lastRenderedRole: "",
+ hasRenderedUser: false,
+ lastDateSeparatorTimestamp: null,
+ turnDividers: new Map(),
+ // Preserve an explicit worked-for toggle across authoritative snapshot
+ // rebuilds. Unset entries follow the official default: the latest turn is
+ // open while older completed turns remain compact.
+ turnExpansion: new Map(),
+ // Legacy attach snapshots may omit turnId on individual projected items.
+ // Keep the derived association by object identity for the duration of a
+ // snapshot so all grouping/timing paths use the same anonymous turn key.
+ structuredTurnKeys: new WeakMap(),
+ liveActivityKey: null,
+ pendingUserArticle: null,
+ outputDistanceFromBottom: 0,
+ timelineAnchorLockUntil: 0,
+ timelineAnchorCancel: null,
+ timelineRevealCancel: null,
+ };
+ const RESPONDABLE_METHODS = new Set([
+ "item/commandExecution/requestApproval",
+ "item/fileChange/requestApproval",
+ "item/permissions/requestApproval",
+ "item/tool/requestUserInput",
+ "mcpServer/elicitation/request",
+ "applyPatchApproval",
+ "execCommandApproval",
+ ]);
+
+ const requestKey = (requestId) => `${typeof requestId}:${String(requestId)}`;
+
+ function normalizeControlMode(value) {
+ const normalized = String(value || "").trim().toLowerCase();
+ return normalized === "sync" || normalized === "async" ? normalized : "";
+ }
+
+ function normalizedControlCapabilities(value) {
+ const source = isRecord(value) ? value : {};
+ return {
+ followsVscodeRoute: source.followsVscodeRoute === true,
+ sessionList: source.sessionList === true,
+ sessionSelect: source.sessionSelect === true,
+ sessionCreate: source.sessionCreate === true,
+ threadSettings: source.threadSettings === true,
+ };
+ }
+
+ function sessionControlAllowed(capability) {
+ return state.modeSnapshotReady
+ && state.controlMode === "async"
+ && state.capabilities[capability] === true;
+ }
+
+ function threadSettingsAllowed() {
+ return state.modeSnapshotReady && state.capabilities.threadSettings === true;
+ }
+
+ function controlModeChangeBlocked() {
+ return !state.modeSnapshotReady
+ || state.modeSwitching
+ || state.sessionSwitching
+ || !state.appReady
+ || !state.ws
+ || state.ws.readyState !== WebSocket.OPEN
+ || !["operator", "owner", "host"].includes(String(state.role || ""))
+ || Boolean(state.turnId)
+ || state.turnStartedAt !== null
+ || state.turnStatus === "active"
+ || state.turnStatus === "waiting"
+ || Boolean(state.pendingUserText)
+ || state.requests.size > 0
+ || state.responding.size > 0
+ || Boolean(state.newSessionCommandId)
+ || Boolean(state.sessionListCommandId)
+ || Boolean(state.sessionSelectCommandId)
+ || state.modelUpdatePending;
+ }
+
+ function clearControlModeRequest() {
+ state.modeSwitching = false;
+ state.modeCommandId = "";
+ state.requestedControlMode = "";
+ state.modeRequestEpoch = -1;
+ }
+
+ function renderControlMode() {
+ const control = $("controlModeSwitch");
+ if (!control) return;
+ const listAllowed = sessionControlAllowed("sessionList");
+ const createAllowed = sessionControlAllowed("sessionCreate");
+ const settingsAllowed = threadSettingsAllowed();
+ const blocked = controlModeChangeBlocked();
+ control.dataset.mode = state.controlMode;
+ control.dataset.epoch = String(state.modeEpoch);
+ control.dataset.switching = String(state.modeSwitching);
+ control.setAttribute("aria-label", t("控制模式"));
+ control.setAttribute("aria-busy", String(state.modeSwitching));
+ for (const button of control.querySelectorAll("[data-control-mode]")) {
+ const mode = normalizeControlMode(button.dataset.controlMode);
+ const current = mode === state.controlMode;
+ const pending = state.modeSwitching && mode === state.requestedControlMode;
+ button.textContent = t(mode === "async" ? "异步" : "同步");
+ button.title = t(mode === "async" ? "异步模式可独立管理会话" : "同步模式跟随 VS Code 当前会话");
+ button.setAttribute("aria-pressed", String(current));
+ button.dataset.pending = String(pending);
+ button.disabled = blocked || current;
+ }
+
+ const sessionPickerButton = $("sessionPickerButton");
+ if (sessionPickerButton) {
+ sessionPickerButton.disabled = !listAllowed || state.modeSwitching || state.sessionSwitching;
+ sessionPickerButton.setAttribute("aria-disabled", String(sessionPickerButton.disabled));
+ }
+ for (const id of ["backButton", "historyButton"]) {
+ const button = $(id);
+ if (button) button.hidden = !listAllowed;
+ }
+ const newSessionButton = $("newSessionButton");
+ if (newSessionButton) newSessionButton.hidden = !createAllowed;
+ const sessionsMenuItem = document.querySelector('[data-menu-action="sessions"]');
+ if (sessionsMenuItem) sessionsMenuItem.hidden = !listAllowed;
+ const refresh = $("sessionPickerRefresh");
+ if (refresh) refresh.disabled = !listAllowed || state.modeSwitching;
+ if (!listAllowed && state.sessionPickerOpen) setSessionPicker(false);
+
+ for (const action of document.querySelectorAll('[data-settings-action="model"], [data-settings-action="permission"]')) {
+ action.disabled = !settingsAllowed || state.modeSwitching || state.sessionSwitching;
+ }
+ for (const id of ["modelPickerButton", "permissionChip"]) {
+ const button = $(id);
+ if (button) button.disabled = !settingsAllowed || state.modeSwitching || state.sessionSwitching;
+ }
+ if (!settingsAllowed) {
+ setModelMenu(false);
+ setPermissionMenu(false);
+ }
+ }
+
+ function applyControlModeSnapshot(metadata) {
+ if (!isRecord(metadata)) return false;
+ const mode = normalizeControlMode(metadata.controlMode);
+ const epoch = finiteNumber(metadata.modeEpoch);
+ if (!mode || epoch === null || (state.modeSnapshotReady && epoch < state.modeEpoch)) return false;
+ const wasSwitching = state.modeSwitching;
+ const requestedMode = state.requestedControlMode;
+ const requestEpoch = state.modeRequestEpoch;
+ state.controlMode = mode;
+ state.modeEpoch = epoch;
+ state.capabilities = normalizedControlCapabilities(metadata.capabilities);
+ state.modeSnapshotReady = true;
+ const requestResolved = wasSwitching && (epoch > requestEpoch || mode === requestedMode);
+ if (requestResolved) {
+ clearControlModeRequest();
+ setConversationStatus(mode === requestedMode ? "控制模式已切换" : "控制模式切换失败", mode === requestedMode ? "ready" : "warning");
+ }
+ updateIds();
+ return true;
+ }
+
+ function resolveRequestKey(requestId) {
+ const exact = requestKey(requestId);
+ if (state.requests.has(exact)) return exact;
+ const text = String(requestId);
+ const candidates = [...state.requests]
+ .filter(([, request]) => String(request.requestId) === text)
+ .map(([key]) => key);
+ return candidates.length === 1 ? candidates[0] : exact;
+ }
+
+ function shouldFollowOutput(output) {
+ return output.scrollHeight - output.scrollTop - output.clientHeight <= 24;
+ }
+
+ const disclosureAnimations = new WeakMap();
+ const activityAnimations = new WeakMap();
+ const commandAnimations = new WeakMap();
+
+ function motionDuration(value = 220) {
+ try {
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) return 0;
+ } catch {
+ // Older embedded webviews may not expose matchMedia.
+ }
+ return Math.max(0, Number(value) || 0);
+ }
+
+ function motionClock() {
+ return typeof performance === "object" && typeof performance.now === "function"
+ ? performance.now()
+ : Date.now();
+ }
+
+ function scheduleFrame(callback) {
+ if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback);
+ return setTimeout(callback, 0);
+ }
+
+ function cancelFrame(frame) {
+ if (frame === null || frame === undefined) return;
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame);
+ clearTimeout(frame);
+ }
+
+ function runMeasuredCssTransition(element, from, to, duration, onFinish) {
+ if (!element) return null;
+ const priorTransition = element.style.transition;
+ let frame = null;
+ let timer = null;
+ let cancelled = false;
+ const setStyles = (values) => {
+ for (const [property, value] of Object.entries(values)) element.style[property] = String(value);
+ };
+ const finish = () => {
+ if (cancelled) return;
+ cancelled = true;
+ cancelFrame(frame);
+ frame = null;
+ if (timer !== null) clearTimeout(timer);
+ timer = null;
+ element.style.transition = priorTransition;
+ onFinish?.();
+ };
+ const controller = {
+ cancel() {
+ if (cancelled) return;
+ cancelled = true;
+ cancelFrame(frame);
+ frame = null;
+ if (timer !== null) clearTimeout(timer);
+ timer = null;
+ element.style.transition = priorTransition;
+ },
+ };
+ const properties = Object.keys(to).map((property) => property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
+ element.style.transition = properties
+ .map((property) => `${property} ${duration}ms cubic-bezier(.33,1,.68,1)`)
+ .join(", ");
+ setStyles(from);
+ // Force a layout boundary before moving to the measured target. This is
+ // the fallback for embedded Chromium builds without Element.animate().
+ void element.offsetHeight;
+ frame = scheduleFrame(() => {
+ frame = null;
+ if (cancelled) return;
+ setStyles(to);
+ });
+ timer = setTimeout(finish, duration + 55);
+ return controller;
+ }
+
+ // Keep the element nearest the reader at the same viewport coordinate while
+ // a disclosure or streamed row changes height. This mirrors the official
+ // preserve-timeline-anchor-position helper and avoids bottom-relative jumps
+ // when the reader is inspecting an earlier turn.
+ function preserveTimelineAnchor(anchor, duration = 250) {
+ const target = anchor?.nodeType === 1 ? anchor : null;
+ const output = target?.closest?.(".chat-scroll") || $("output");
+ if (!target || !output || !target.isConnected || !output.isConnected) return;
+ if (typeof state.timelineAnchorCancel === "function") state.timelineAnchorCancel();
+ const initialTop = target.getBoundingClientRect().top;
+ if (!Number.isFinite(initialTop)) return;
+ const deadline = motionClock() + duration + 120;
+ state.timelineAnchorLockUntil = Math.max(state.timelineAnchorLockUntil || 0, deadline);
+ let frame = null;
+ let timer = null;
+ let disposed = false;
+ const adjust = () => {
+ if (disposed || !target.isConnected || !output.isConnected) return;
+ const delta = target.getBoundingClientRect().top - initialTop;
+ if (Number.isFinite(delta) && Math.abs(delta) >= 0.1) {
+ const maxScroll = Math.max(0, output.scrollHeight - output.clientHeight);
+ output.scrollTop = Math.max(0, Math.min(maxScroll, output.scrollTop + delta));
+ updateScrollToBottom(output);
+ }
+ // A disclosure can change height without producing a ResizeObserver
+ // callback on older Chromium builds. Keep one frame queued for the full
+ // measured transition so the scrollbar and reader anchor move together.
+ if (motionClock() < deadline) schedule();
+ };
+ const schedule = () => {
+ if (disposed || frame !== null) return;
+ frame = scheduleFrame(() => {
+ frame = null;
+ adjust();
+ });
+ };
+ const immediate = () => {
+ if (frame !== null) {
+ cancelFrame(frame);
+ frame = null;
+ }
+ adjust();
+ schedule();
+ };
+ const observed = target.closest("[data-turn-key]") || target.closest(".message") || target;
+ let observer = null;
+ if (typeof ResizeObserver === "function") {
+ observer = new ResizeObserver(immediate);
+ observer.observe(observed);
+ if (observed !== target) observer.observe(target);
+ }
+ schedule();
+ const finish = () => {
+ if (disposed) return;
+ disposed = true;
+ cancelFrame(frame);
+ frame = null;
+ if (timer !== null) clearTimeout(timer);
+ timer = null;
+ observer?.disconnect();
+ if (state.timelineAnchorCancel === finish) state.timelineAnchorCancel = null;
+ };
+ state.timelineAnchorCancel = finish;
+ timer = setTimeout(finish, Math.max(250, duration + 150));
+ }
+
+ function setDisclosureBodyState(details, expanded) {
+ const body = details?.querySelector?.(":scope > .details-body");
+ if (!body) return;
+ body.style.display = "block";
+ body.style.height = expanded ? "auto" : "0px";
+ body.style.opacity = expanded ? "1" : "0";
+ body.style.overflow = expanded ? "" : "hidden";
+ body.style.pointerEvents = expanded ? "auto" : "none";
+ body.dataset.disclosureState = expanded ? "expanded" : "collapsed";
+ details.dataset.expanded = String(Boolean(expanded));
+ details.querySelector("summary")?.setAttribute("aria-expanded", String(Boolean(expanded)));
+ }
+
+ function animateDisclosureBody(details, expanded, options = {}) {
+ const body = details?.querySelector?.(":scope > .details-body");
+ if (!body) return;
+ const previous = disclosureAnimations.get(body);
+ if (previous) {
+ try { previous.commitStyles?.(); } catch { /* animation may already be finished */ }
+ previous.cancel();
+ }
+ disclosureAnimations.delete(body);
+ const next = Boolean(expanded);
+ details.dataset.expanded = String(next);
+ details.dataset.animating = "true";
+ body.style.display = "block";
+ const duration = motionDuration(options.duration ?? 220);
+ const computed = getComputedStyle(body);
+ const currentHeight = Math.max(0, body.getBoundingClientRect().height || 0);
+ const currentOpacity = Number.parseFloat(computed.opacity);
+ const fromOpacity = Number.isFinite(currentOpacity) ? currentOpacity : (next ? 0 : 1);
+ if (options.immediate || duration === 0) {
+ setDisclosureBodyState(details, next);
+ details.dataset.animating = "false";
+ return;
+ }
+ let targetHeight = 0;
+ if (next) {
+ body.style.height = "auto";
+ body.style.opacity = "1";
+ body.style.overflow = "hidden";
+ targetHeight = Math.max(0, body.getBoundingClientRect().height || body.scrollHeight || 0);
+ body.style.height = `${currentHeight}px`;
+ } else {
+ body.style.height = `${currentHeight}px`;
+ body.style.opacity = String(fromOpacity);
+ body.style.overflow = "hidden";
+ }
+ if (Math.abs(targetHeight - currentHeight) < 0.5 && (next ? fromOpacity >= 0.99 : fromOpacity <= 0.01)) {
+ setDisclosureBodyState(details, next);
+ details.dataset.animating = "false";
+ return;
+ }
+ if (typeof body.animate !== "function") {
+ let transition;
+ transition = runMeasuredCssTransition(
+ body,
+ { height: `${currentHeight}px`, opacity: fromOpacity },
+ { height: `${targetHeight}px`, opacity: next ? 1 : 0 },
+ duration,
+ () => {
+ if (disclosureAnimations.get(body) !== transition) return;
+ disclosureAnimations.delete(body);
+ setDisclosureBodyState(details, next);
+ details.dataset.animating = "false";
+ },
+ );
+ if (transition) disclosureAnimations.set(body, transition);
+ return;
+ }
+ const animation = body.animate([
+ { height: `${currentHeight}px`, opacity: fromOpacity },
+ { height: `${targetHeight}px`, opacity: next ? 1 : 0 },
+ ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" });
+ disclosureAnimations.set(body, animation);
+ animation.addEventListener("finish", () => {
+ if (disclosureAnimations.get(body) !== animation) return;
+ disclosureAnimations.delete(body);
+ setDisclosureBodyState(details, next);
+ details.dataset.animating = "false";
+ // `fill: forwards` keeps the animation in the cascade after finish.
+ // Release it only after the stable inline state is written, otherwise a
+ // later open can still measure the previous collapsed height (zero).
+ animation.cancel();
+ }, { once: true });
+ animation.addEventListener("cancel", () => {
+ if (disclosureAnimations.get(body) === animation) disclosureAnimations.delete(body);
+ }, { once: true });
+ }
+
+ function installDisclosure(details, initiallyOpen) {
+ if (!details || details.dataset.disclosureInstalled === "true") return;
+ const summary = details.querySelector(":scope > summary");
+ const body = details.querySelector(":scope > .details-body");
+ if (!summary || !body) return;
+ details.dataset.disclosureInstalled = "true";
+ // Keep the native details element mounted. The summary still supplies the
+ // familiar keyboard/focus semantics, while the body itself is animated
+ // with measured height so a close does not jump the transcript.
+ details.open = true;
+ setDisclosureBodyState(details, Boolean(initiallyOpen));
+ summary.addEventListener("click", (event) => {
+ event.preventDefault();
+ setDetailsExpanded(details, !isDetailsExpanded(details));
+ }, true);
+ details.addEventListener("toggle", () => {
+ // A browser/plugin script may still assign `.open = false`; restore the
+ // mounted shell and leave the visual state under our measured body.
+ if (!details.open && !details.__codexRestoring) {
+ details.__codexRestoring = true;
+ details.open = true;
+ details.__codexRestoring = false;
+ }
+ });
+ }
+
+ function isDetailsExpanded(details) {
+ if (!details) return false;
+ if (details.dataset.expanded !== undefined) return details.dataset.expanded === "true";
+ return details.open === true;
+ }
+
+ function setDetailsExpanded(details, expanded, options = {}) {
+ if (!details) return;
+ const next = Boolean(expanded);
+ const previous = isDetailsExpanded(details);
+ const installed = details.dataset.disclosureInstalled === "true";
+ if (!installed) {
+ details.open = next;
+ return;
+ }
+ if (previous === next && !options.force) {
+ if (options.immediate) animateDisclosureBody(details, next, { immediate: true });
+ return;
+ }
+ details.dataset.expanded = String(next);
+ if (options.preserve !== false && !options.immediate) preserveTimelineAnchor(details, options.duration ?? 230);
+ animateDisclosureBody(details, next, { immediate: Boolean(options.immediate), duration: options.duration });
+ if (next && !options.immediate && options.reveal !== false) {
+ scheduleTimelineReveal(details.querySelector(":scope > .details-body") || details, options.duration ?? 220);
+ }
+ }
+
+ function animateActivityArticle(article, expanded, options = {}) {
+ if (!article) return;
+ const previous = activityAnimations.get(article);
+ if (previous) {
+ try { previous.commitStyles?.(); } catch { /* animation may already be finished */ }
+ previous.cancel();
+ }
+ activityAnimations.delete(article);
+ const next = Boolean(expanded);
+ const duration = motionDuration(options.duration ?? 230);
+ const wasCollapsed = article.classList.contains("turn-collapsed");
+ article.dataset.turnExpanded = String(next);
+
+ // `worked-for` is an outer disclosure. The official renderer removes the
+ // whole activity group from layout while it is collapsed, rather than
+ // leaving one summary row per command. Animate the article shell itself;
+ // nested command/read disclosures keep their own expanded state.
+ if (next) article.classList.remove("turn-collapsed");
+ article.style.display = "";
+ article.style.height = "";
+ article.style.opacity = "";
+ article.style.visibility = "";
+ article.style.pointerEvents = "";
+ article.style.overflow = "";
+ const currentHeight = Math.max(0, article.getBoundingClientRect().height || 0);
+ const naturalHeight = Math.max(0, article.scrollHeight || currentHeight);
+ const fromHeight = next && wasCollapsed ? 0 : currentHeight;
+ const targetHeight = next ? naturalHeight : 0;
+ const fromOpacity = next ? (wasCollapsed ? 0 : 1) : 1;
+ const targetOpacity = next ? 1 : 0;
+ const finish = () => {
+ if (next) {
+ article.classList.remove("turn-collapsed");
+ article.style.height = "";
+ article.style.opacity = "";
+ article.style.visibility = "";
+ article.style.pointerEvents = "";
+ article.style.overflow = "";
+ } else {
+ article.classList.add("turn-collapsed");
+ article.style.height = "0px";
+ article.style.opacity = "0";
+ article.style.visibility = "hidden";
+ article.style.pointerEvents = "none";
+ article.style.overflow = "hidden";
+ }
+ };
+ if (options.immediate || duration === 0 || Math.abs(targetHeight - fromHeight) < 0.5) {
+ finish();
+ return;
+ }
+ article.style.height = `${fromHeight}px`;
+ article.style.opacity = String(fromOpacity);
+ article.style.overflow = "hidden";
+ if (typeof article.animate !== "function") {
+ let transition;
+ transition = runMeasuredCssTransition(
+ article,
+ { height: `${fromHeight}px`, opacity: fromOpacity },
+ { height: `${targetHeight}px`, opacity: targetOpacity },
+ duration,
+ () => {
+ if (activityAnimations.get(article) !== transition) return;
+ activityAnimations.delete(article);
+ finish();
+ },
+ );
+ if (transition) activityAnimations.set(article, transition);
+ return;
+ }
+ const animation = article.animate([
+ { height: `${fromHeight}px`, opacity: fromOpacity },
+ { height: `${targetHeight}px`, opacity: targetOpacity },
+ ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" });
+ activityAnimations.set(article, animation);
+ animation.addEventListener("finish", () => {
+ if (activityAnimations.get(article) !== animation) return;
+ activityAnimations.delete(article);
+ finish();
+ animation.cancel();
+ }, { once: true });
+ animation.addEventListener("cancel", () => {
+ if (activityAnimations.get(article) === animation) activityAnimations.delete(article);
+ }, { once: true });
+ }
+
+ function animateCommandRow(commandRow, expanded, options = {}) {
+ if (!commandRow) return;
+ const previous = commandAnimations.get(commandRow);
+ if (previous) {
+ try { previous.commitStyles?.(); } catch { /* animation may already be finished */ }
+ previous.cancel();
+ }
+ commandAnimations.delete(commandRow);
+ const next = Boolean(expanded);
+ const duration = motionDuration(options.duration ?? 190);
+ const fromHeight = Math.max(0, commandRow.getBoundingClientRect().height || 0);
+ commandRow.dataset.expanded = String(next);
+ commandRow.setAttribute("aria-expanded", String(next));
+ commandRow.style.overflow = "hidden";
+ commandRow.style.height = "auto";
+ const targetHeight = Math.max(0, commandRow.getBoundingClientRect().height || 0);
+ if (options.immediate || duration === 0 || Math.abs(targetHeight - fromHeight) < 0.5) {
+ commandRow.style.height = "";
+ commandRow.style.overflow = "";
+ return;
+ }
+ if (typeof commandRow.animate !== "function") {
+ let transition;
+ transition = runMeasuredCssTransition(
+ commandRow,
+ { height: `${fromHeight}px` },
+ { height: `${targetHeight}px` },
+ duration,
+ () => {
+ if (commandAnimations.get(commandRow) !== transition) return;
+ commandAnimations.delete(commandRow);
+ commandRow.style.height = "";
+ commandRow.style.overflow = "";
+ },
+ );
+ if (transition) commandAnimations.set(commandRow, transition);
+ return;
+ }
+ commandRow.style.height = `${fromHeight}px`;
+ const animation = commandRow.animate([
+ { height: `${fromHeight}px` },
+ { height: `${targetHeight}px` },
+ ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" });
+ commandAnimations.set(commandRow, animation);
+ animation.addEventListener("finish", () => {
+ if (commandAnimations.get(commandRow) !== animation) return;
+ commandAnimations.delete(commandRow);
+ commandRow.style.height = "";
+ commandRow.style.overflow = "";
+ animation.cancel();
+ }, { once: true });
+ animation.addEventListener("cancel", () => {
+ if (commandAnimations.get(commandRow) === animation) commandAnimations.delete(commandRow);
+ }, { once: true });
+ }
+
+ function updateScrollToBottom(output = $("output")) {
+ const button = $("scrollToBottom");
+ if (!button || !output) return;
+ const distance = Math.max(0, output.scrollHeight - output.scrollTop - output.clientHeight);
+ state.outputDistanceFromBottom = distance;
+ const visible = distance > 24;
+ const working = state.turnStartedAt !== null || state.turnStatus === "active" || state.turnStatus === "waiting";
+ button.dataset.visible = String(visible);
+ button.dataset.working = String(working);
+ button.setAttribute("aria-label", t(working ? "正在工作,回到最新消息" : "回到最新消息"));
+ button.setAttribute("aria-hidden", String(!visible));
+ button.tabIndex = visible ? 0 : -1;
+ }
+
+ function scrollOutput(output, force = false) {
+ if (!output) return;
+ if (force || shouldFollowOutput(output)) {
+ if (typeof output.scrollTo === "function") output.scrollTo({ top: output.scrollHeight, behavior: "auto" });
+ else output.scrollTop = output.scrollHeight;
+ }
+ updateScrollToBottom(output);
+ }
+
+ function updateScrollPadding() {
+ const output = $("output");
+ const panel = document.querySelector(".chat-panel");
+ const composer = $("messageForm");
+ if (!output || !panel || !composer) return;
+ const outputRect = output.getBoundingClientRect();
+ const composerRect = composer.getBoundingClientRect();
+ // The composer is an overlay in the official panel. Reserve only the
+ // portion that actually covers the scroll viewport, plus a small gap.
+ const overlap = Math.max(0, Math.ceil(outputRect.bottom - composerRect.top));
+ const reserve = Math.max(72, overlap + 16);
+ output.style.setProperty("--thread-scroll-padding-bottom", `${reserve}px`);
+ panel.style.setProperty("--thread-scroll-padding-bottom", `${reserve}px`);
+ updateScrollToBottom(output);
+ }
+
+ function timelineVisibleBounds(output) {
+ if (!output) return null;
+ const outputRect = output.getBoundingClientRect();
+ const composer = $("messageForm");
+ const composerRect = composer?.getBoundingClientRect?.();
+ const top = outputRect.top + 8;
+ const composerTop = composerRect && Number.isFinite(composerRect.top) ? composerRect.top - 10 : outputRect.bottom - 8;
+ const bottom = Math.min(outputRect.bottom - 8, composerTop);
+ return { top, bottom: Math.max(top, bottom) };
+ }
+
+ // Keep an expanding activity row inside the portion of the transcript that
+ // is actually readable above the composer. This runs across the measured
+ // height animation because one layout pass is not enough when streamed
+ // command output arrives at the same time.
+ function ensureTimelineVisible(target) {
+ const element = target?.nodeType === 1 ? target : null;
+ const output = element?.closest?.(".chat-scroll") || $("output");
+ if (!element || !output || !element.isConnected || !output.isConnected) return;
+ const bounds = timelineVisibleBounds(output);
+ if (!bounds) return;
+ const rect = element.getBoundingClientRect();
+ let delta = 0;
+ if (rect.top < bounds.top) delta = rect.top - bounds.top;
+ else if (rect.bottom > bounds.bottom) delta = rect.bottom - bounds.bottom;
+ if (!Number.isFinite(delta) || Math.abs(delta) < 0.25) return;
+ const maxScroll = Math.max(0, output.scrollHeight - output.clientHeight);
+ output.scrollTop = Math.max(0, Math.min(maxScroll, output.scrollTop + delta));
+ updateScrollToBottom(output);
+ }
+
+ function scheduleTimelineReveal(target, duration = 220) {
+ const element = target?.nodeType === 1 ? target : null;
+ if (!element) return;
+ if (typeof state.timelineRevealCancel === "function") state.timelineRevealCancel();
+ const deadline = motionClock() + motionDuration(duration) + 90;
+ let frame = null;
+ let timer = null;
+ let disposed = false;
+ const tick = () => {
+ if (disposed) return;
+ frame = null;
+ ensureTimelineVisible(element);
+ if (motionClock() < deadline) frame = scheduleFrame(tick);
+ };
+ const cancel = () => {
+ if (disposed) return;
+ disposed = true;
+ cancelFrame(frame);
+ frame = null;
+ if (timer !== null) clearTimeout(timer);
+ timer = null;
+ if (state.timelineRevealCancel === cancel) state.timelineRevealCancel = null;
+ };
+ state.timelineRevealCancel = cancel;
+ tick();
+ timer = setTimeout(cancel, Math.max(180, motionDuration(duration) + 130));
+ }
+
+ function comparableText(value) {
+ return String(value ?? "").replace(/\s+/g, " ").trim();
+ }
+
+ function hasRenderedMessage(text, turnId, role) {
+ const target = comparableText(text);
+ if (!target) return false;
+ const output = $("output");
+ if (!output) return false;
+ return [...output.querySelectorAll(`.message.${role}`)].some((article) => {
+ if (turnId && article.dataset.turnId && article.dataset.turnId !== String(turnId)) return false;
+ return comparableText(article.dataset.rawText) === target;
+ });
+ }
+
+ function hasRenderedCompletedMessage(text, turnId, role) {
+ const target = comparableText(text);
+ if (!target) return false;
+ const output = $("output");
+ if (!output) return false;
+ return [...output.querySelectorAll(`.message.${role}`)].some((article) => {
+ if (article.classList.contains("streaming")) return false;
+ if (turnId && article.dataset.turnId && article.dataset.turnId !== String(turnId)) return false;
+ const raw = comparableText(article.dataset.rawText);
+ return raw === target || raw.includes(target) || target.includes(raw);
+ });
+ }
+
+ function appendInlineMarkdown(parent, source) {
+ const pattern = /(\[[^\]]+\]\(https?:\/\/[^)\s]+\)|`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|~~[^~\n]+~~|\*[^*\n]+\*|_[^_\n]+_)/g;
+ let cursor = 0;
+ const appendText = (value) => {
+ const parts = String(value).split("\n");
+ parts.forEach((part, index) => {
+ if (part) parent.append(document.createTextNode(part));
+ if (index < parts.length - 1) parent.append(document.createElement("br"));
+ });
+ };
+ for (const match of String(source).matchAll(pattern)) {
+ if (match.index > cursor) appendText(String(source).slice(cursor, match.index));
+ const token = match[0];
+ if (token.startsWith("[") && token.endsWith(")")) {
+ const split = token.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
+ if (split) {
+ const link = document.createElement("a");
+ link.href = split[2];
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ link.textContent = split[1];
+ parent.append(link);
+ } else appendText(token);
+ } else if (token.startsWith("`") && token.endsWith("`")) {
+ const code = document.createElement("code");
+ code.textContent = token.slice(1, -1);
+ parent.append(code);
+ } else if (token.startsWith("**") || token.startsWith("__")) {
+ const strong = document.createElement("strong");
+ strong.textContent = token.slice(2, -2);
+ parent.append(strong);
+ } else if (token.startsWith("~~")) {
+ const deleted = document.createElement("del");
+ deleted.textContent = token.slice(2, -2);
+ parent.append(deleted);
+ } else if (token.startsWith("*") || token.startsWith("_")) {
+ const emphasis = document.createElement("em");
+ emphasis.textContent = token.slice(1, -1);
+ parent.append(emphasis);
+ } else appendText(token);
+ cursor = match.index + token.length;
+ }
+ if (cursor < String(source).length) appendText(String(source).slice(cursor));
+ }
+
+ function tableCells(line) {
+ let value = String(line || "").trim();
+ if (value.startsWith("|")) value = value.slice(1);
+ if (value.endsWith("|")) value = value.slice(0, -1);
+ return value.split("|").map((cell) => cell.trim());
+ }
+
+ function isTableDivider(line) {
+ const cells = tableCells(line);
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
+ }
+
+ function appendTable(container, headerLine, bodyLines) {
+ const table = document.createElement("table");
+ const thead = document.createElement("thead");
+ const header = document.createElement("tr");
+ for (const cell of tableCells(headerLine)) {
+ const th = document.createElement("th");
+ appendInlineMarkdown(th, cell);
+ header.append(th);
+ }
+ thead.append(header);
+ table.append(thead);
+ const tbody = document.createElement("tbody");
+ for (const line of bodyLines) {
+ const row = document.createElement("tr");
+ for (const cell of tableCells(line)) {
+ const td = document.createElement("td");
+ appendInlineMarkdown(td, cell);
+ row.append(td);
+ }
+ tbody.append(row);
+ }
+ table.append(tbody);
+ container.append(table);
+ }
+
+ /**
+ * Render the small, safe Markdown subset used by Codex messages. The
+ * official webview uses a full Markdown/ProseMirror pipeline; the relay
+ * intentionally keeps this browser-side renderer dependency-free and never
+ * assigns untrusted text to innerHTML.
+ */
+ function renderMarkdown(container, source) {
+ container.replaceChildren();
+ const lines = String(source ?? "").replace(/\r\n?/g, "\n").split("\n");
+ let index = 0;
+ const addParagraph = (paragraph) => {
+ if (!paragraph.length) return;
+ const element = document.createElement("p");
+ appendInlineMarkdown(element, paragraph.join("\n"));
+ container.append(element);
+ };
+ while (index < lines.length) {
+ const line = lines[index];
+ if (!line.trim()) { index += 1; continue; }
+ const fence = line.match(/^\s*```\s*([\w.+-]*)\s*$/);
+ if (fence) {
+ index += 1;
+ const codeLines = [];
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) codeLines.push(lines[index++]);
+ if (index < lines.length) index += 1;
+ const pre = document.createElement("pre");
+ const code = document.createElement("code");
+ if (fence[1]) code.dataset.language = fence[1];
+ code.textContent = codeLines.join("\n");
+ pre.append(code);
+ container.append(pre);
+ continue;
+ }
+ if (index + 1 < lines.length && line.includes("|") && isTableDivider(lines[index + 1])) {
+ const body = [];
+ index += 2;
+ while (index < lines.length && lines[index].trim() && lines[index].includes("|")) body.push(lines[index++]);
+ appendTable(container, line, body);
+ continue;
+ }
+ if (/^\s*(?:---+|___+|\*\*\*+)\s*$/.test(line)) {
+ container.append(document.createElement("hr"));
+ index += 1;
+ continue;
+ }
+ const heading = line.match(/^\s*(#{1,3})\s+(.+?)\s*#*$/);
+ if (heading) {
+ const element = document.createElement(`h${heading[1].length}`);
+ appendInlineMarkdown(element, heading[2]);
+ container.append(element);
+ index += 1;
+ continue;
+ }
+ if (/^\s*>\s?/.test(line)) {
+ const quote = document.createElement("blockquote");
+ while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
+ const paragraph = document.createElement("p");
+ appendInlineMarkdown(paragraph, lines[index].replace(/^\s*>\s?/, ""));
+ quote.append(paragraph);
+ index += 1;
+ }
+ container.append(quote);
+ continue;
+ }
+ const list = line.match(/^\s*([-*+]|\d+[.)])\s+(.+)$/);
+ if (list) {
+ const ordered = /^\d/.test(list[1]);
+ const listElement = document.createElement(ordered ? "ol" : "ul");
+ while (index < lines.length) {
+ const item = lines[index].match(/^\s*([-*+]|\d+[.)])\s+(.+)$/);
+ if (!item || /^\d/.test(item[1]) !== ordered) break;
+ const li = document.createElement("li");
+ const task = item[2].match(/^\[([ xX])\]\s+(.+)$/);
+ if (task) {
+ li.className = "task-list-item";
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.checked = task[1].toLowerCase() === "x";
+ checkbox.disabled = true;
+ checkbox.setAttribute("aria-label", t(checkbox.checked ? "已完成" : "未完成"));
+ li.append(checkbox);
+ appendInlineMarkdown(li, task[2]);
+ } else appendInlineMarkdown(li, item[2]);
+ listElement.append(li);
+ index += 1;
+ }
+ container.append(listElement);
+ continue;
+ }
+ const paragraph = [line];
+ index += 1;
+ while (index < lines.length
+ && lines[index].trim()
+ && !/^\s*```/.test(lines[index])
+ && !/^\s*(#{1,3})\s+/.test(lines[index])
+ && !/^\s*>\s?/.test(lines[index])
+ && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[index])) {
+ paragraph.push(lines[index++]);
+ }
+ addParagraph(paragraph);
+ }
+ }
+
+ function renderMessageBody(body, text, role, tone, kind) {
+ const value = String(text ?? "");
+ body.dataset.rawText = value;
+ // User-authored prompts use the same safe Markdown subset as assistant
+ // messages. Tool/status rows stay literal so command output cannot be
+ // mistaken for formatted content.
+ const markdownRole = role === "assistant" || role === "user"
+ || kind === "reasoning" || kind === "plan" || kind === "subagent" || kind === "commentary";
+ body.classList.toggle("markdown-body", markdownRole && tone !== "meta" && tone !== "error" && kind !== "tool");
+ body.classList.toggle("diff-body", kind === "edit");
+ if (kind === "edit") {
+ body.replaceChildren();
+ const output = document.createElement("pre");
+ output.className = "diff-output";
+ for (const line of value.replace(/\r\n?/g, "\n").split("\n")) {
+ const row = document.createElement("span");
+ row.className = line.startsWith("+") && !line.startsWith("+++")
+ ? "diff-line added"
+ : line.startsWith("-") && !line.startsWith("---")
+ ? "diff-line removed"
+ : line.startsWith("@@") || line.startsWith("diff ") || line.startsWith("[")
+ ? "diff-line context"
+ : "diff-line";
+ row.textContent = line || " ";
+ output.append(row);
+ }
+ body.append(output);
+ } else if (kind === "tool" || kind === "read") body.textContent = value;
+ else if (markdownRole && tone !== "meta" && tone !== "error") renderMarkdown(body, value);
+ else body.textContent = value;
+ }
+
+ function createTerminalIcon() {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.className.baseVal = "activity-summary-icon terminal-icon";
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("aria-hidden", "true");
+ const frame = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ frame.setAttribute("x", "2.25");
+ frame.setAttribute("y", "2.75");
+ frame.setAttribute("width", "11.5");
+ frame.setAttribute("height", "10.5");
+ frame.setAttribute("rx", "1.4");
+ const prompt = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ prompt.setAttribute("d", "m4.5 6 2 2-2 2");
+ const cursor = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ cursor.setAttribute("d", "M8.5 10h2.5");
+ svg.append(frame, prompt, cursor);
+ return svg;
+ }
+
+ function createSubagentIcon() {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.className.baseVal = "activity-summary-icon subagent-summary-icon";
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("aria-hidden", "true");
+ // The installed Codex webview uses the filled blossom mark for both
+ // sub-agent activity rows and the composer agent rail. Keep the path
+ // inline so the standalone relay page does not depend on VS Code's URI
+ // resolver or on an extension-owned asset path.
+ const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ mark.setAttribute("fill", "currentColor");
+ mark.setAttribute("d", "M13.795 23.856q-1.188 0-2.256-.448a6.1 6.1 0 0 1-1.9-1.247 5.8 5.8 0 0 1-1.875.306 5.8 5.8 0 0 1-2.944-.777 6.1 6.1 0 0 1-2.184-2.12q-.807-1.34-.808-2.99 0-.682.19-1.482a6.3 6.3 0 0 1-1.472-2.002 5.76 5.76 0 0 1 .024-4.85q.546-1.177 1.52-2.024a5.5 5.5 0 0 1 2.303-1.2A5.55 5.55 0 0 1 5.485 2.62 6.06 6.06 0 0 1 7.575.925 5.85 5.85 0 0 1 10.21.313q1.187 0 2.255.447a6.1 6.1 0 0 1 1.9 1.248 5.8 5.8 0 0 1 1.875-.306q1.59 0 2.944.776a5.9 5.9 0 0 1 2.16 2.12q.832 1.34.832 2.99 0 .682-.19 1.483a6.2 6.2 0 0 1 1.472 2.024q.522 1.13.522 2.378 0 1.272-.546 2.449a6.1 6.1 0 0 1-1.543 2.048 5.45 5.45 0 0 1-2.28 1.177 5.4 5.4 0 0 1-1.115 2.402 5.8 5.8 0 0 1-2.066 1.695 5.85 5.85 0 0 1-2.635.612M7.93 20.913q1.188 0 2.066-.495l4.463-2.542a.52.52 0 0 0 .238-.448v-2.024L8.95 18.676a.97.97 0 0 1-1.044 0L3.419 16.11a.7.7 0 0 1-.024.165v.282q0 1.201.57 2.213.594.99 1.639 1.554 1.044.59 2.326.589m.238-3.838q.143.07.26.07a.46.46 0 0 0 .238-.07l1.781-1.012-5.722-3.296q-.522-.306-.522-.918v-5.11a4.27 4.27 0 0 0-1.9 1.602 4.13 4.13 0 0 0-.712 2.354q0 1.155.594 2.213.593 1.06 1.543 1.601zm5.627 5.227q1.258 0 2.279-.565a4.25 4.25 0 0 0 1.614-1.554q.594-.99.594-2.213v-5.085q0-.283-.237-.424l-1.805-1.036v6.568q0 .613-.522.919l-4.487 2.566q1.163.825 2.564.824m.902-8.617v-3.202l-2.683-1.507-2.707 1.507v3.202l2.707 1.507zm-6.933-7.51q0-.612.522-.918l4.488-2.567a4.34 4.34 0 0 0-2.564-.824q-1.26 0-2.28.565a4.25 4.25 0 0 0-1.614 1.554q-.57.99-.57 2.213v5.062q0 .283.237.447l1.781 1.036zm12.061 11.253a4.13 4.13 0 0 0 1.876-1.6 4.2 4.2 0 0 0 .712-2.355q0-1.154-.593-2.213-.594-1.06-1.544-1.6l-4.44-2.543q-.142-.095-.26-.071a.46.46 0 0 0-.238.07l-1.78.99 5.745 3.319q.26.141.38.377a.9.9 0 0 1 .142.518zm-4.772-11.96q.522-.33 1.045 0l4.51 2.614v-.424q0-1.13-.57-2.142a4.1 4.1 0 0 0-1.59-1.648q-1.02-.613-2.374-.613-1.187 0-2.066.495L9.545 6.292a.52.52 0 0 0-.238.448v2.025z");
+ svg.append(mark);
+ return svg;
+ }
+
+ function createReadIcon() {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.className.baseVal = "activity-summary-icon read-summary-icon";
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "M2.5 4.5h4l1.2 1.4h5.8v6.2c0 .8-.5 1.4-1.3 1.4H3.8c-.8 0-1.3-.6-1.3-1.4V4.5Z");
+ const top = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ top.setAttribute("d", "M2.5 6h11");
+ svg.append(path, top);
+ return svg;
+ }
+
+ // The official sub-agent activity renderer keeps the agent name in a
+ // bordered chip and places the lifecycle text beside it. Keeping those two
+ // nodes separate also lets a running row update only its status without
+ // replacing the blossom icon or the accessible label.
+ function setSubagentSummary(summary, name, statusText = "") {
+ if (!summary) return;
+ let chip = summary.querySelector(".subagent-summary-chip");
+ let label = chip?.querySelector(".subagent-summary-label");
+ let status = summary.querySelector(".subagent-summary-status");
+ if (!chip || !label || !status) {
+ chip = document.createElement("span");
+ chip.className = "subagent-summary-chip";
+ const icon = createSubagentIcon();
+ label = document.createElement("span");
+ label.className = "subagent-summary-label";
+ chip.append(icon, label);
+ status = document.createElement("span");
+ status.className = "subagent-summary-status";
+ summary.replaceChildren(chip, status);
+ }
+ label.textContent = String(name || t("子代理"));
+ status.textContent = statusText ? t(statusText) : "";
+ summary.setAttribute("aria-label", [label.textContent, status.textContent].filter(Boolean).join(" "));
+ }
+
+ function setActivitySummary(summary, text, kind = "") {
+ if (!summary) return;
+ if (kind === "subagent") {
+ const value = String(text || "");
+ const match = value.match(/^(.*?)(?:\s+(已开始工作|已完成|失败|已中断|等待中|处理中|Started working|Completed|Failed|Interrupted|Waiting|Working))$/);
+ setSubagentSummary(summary, match ? match[1] : value || t("子代理"), match ? t(match[2]) : "");
+ return;
+ }
+ if (kind !== "tool" && kind !== "read" && kind !== "subagent") {
+ summary.textContent = String(text || "");
+ return;
+ }
+ let icon = summary.querySelector(".activity-summary-icon");
+ let label = summary.querySelector(".activity-summary-label");
+ if (!icon || !label) {
+ icon = kind === "subagent" ? createSubagentIcon() : kind === "read" ? createReadIcon() : createTerminalIcon();
+ label = document.createElement("span");
+ label.className = "activity-summary-label";
+ summary.replaceChildren(icon, label);
+ }
+ label.textContent = String(text || "");
+ }
+
+ function stripShellQuotes(value) {
+ let text = String(value || "").trim();
+ let changed = true;
+ while (changed) {
+ changed = false;
+ if (text.startsWith("$'") && text.endsWith("'")) {
+ text = text.slice(2, -1).replace(/\\'/g, "'");
+ changed = true;
+ } else if ((text.startsWith("'") && text.endsWith("'"))
+ || (text.startsWith('"') && text.endsWith('"'))) {
+ const quoted = text.startsWith('"');
+ text = text.slice(1, -1);
+ if (quoted) text = text.replace(/\\"/g, '"');
+ changed = true;
+ }
+ }
+ return text.trim();
+ }
+
+ // The official command renderer hides the shell bootstrap used by the IPC
+ // runner (`/bin/zsh -lc '…'`) and shows the actual user command instead.
+ function terminalCommandText(value) {
+ const command = stripShellQuotes(value);
+ const match = command.match(/^(?:.*[/\\])?(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)\s+-lc\s+([\s\S]+)$/i);
+ return match ? stripShellQuotes(match[1]) : command;
+ }
+
+ function addMessageActions(article, enabled = true) {
+ // Tool/reasoning rows have their own disclosure controls and should not
+ // grow a second action rail. Editing an earlier turn is intentionally not
+ // exposed until the relay can perform the official branch/edit operation.
+ if (!enabled || (!article.classList.contains("user") && !article.classList.contains("assistant"))) return null;
+ if (article.classList.contains("assistant") && article.classList.contains("streaming")) return null;
+ const actions = document.createElement("div");
+ actions.className = "message-actions";
+ const copy = document.createElement("button");
+ copy.type = "button";
+ copy.className = "message-action";
+ copy.title = t("复制消息");
+ copy.setAttribute("aria-label", t("复制消息"));
+ const setCopyIcon = (copied = false) => {
+ copy.replaceChildren();
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("aria-hidden", "true");
+ if (copied) {
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3");
+ svg.append(path);
+ } else {
+ const back = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ back.setAttribute("d", "M5.5 5.5V4c0-.8.7-1.5 1.5-1.5h5c.8 0 1.5.7 1.5 1.5v5c0 .8-.7 1.5-1.5 1.5h-1.5");
+ const front = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ front.setAttribute("x", "2.5");
+ front.setAttribute("y", "5.5");
+ front.setAttribute("width", "7.5");
+ front.setAttribute("height", "7.5");
+ front.setAttribute("rx", "1.2");
+ svg.append(back, front);
+ }
+ copy.append(svg);
+ };
+ setCopyIcon();
+ copy.addEventListener("click", async () => {
+ const value = article.dataset.rawText || "";
+ try {
+ await navigator.clipboard?.writeText(value);
+ setCopyIcon(true);
+ setTimeout(() => { setCopyIcon(false); }, 1_200);
+ } catch {
+ copy.textContent = "!";
+ }
+ });
+ actions.append(copy);
+ article.append(actions);
+ return actions;
+ }
+
+ function appendMessage(text, role = "assistant", tone = "text", meta = "", options = {}) {
+ if (text === undefined || text === null) return null;
+ const output = $("output");
+ const follow = shouldFollowOutput(output);
+ const article = document.createElement("article");
+ article.className = `message ${role}${tone && tone !== "text" ? ` ${tone}` : ""}`;
+ if (options.kind) article.dataset.kind = options.kind;
+ if (options.status) article.dataset.status = String(options.status);
+ if (options.turnId) article.dataset.turnId = String(options.turnId);
+ if (options.structuredKey) article.dataset.structuredKey = String(options.structuredKey);
+ if (options.activityKey) article.dataset.activityKey = String(options.activityKey);
+ if (options.itemId !== undefined && options.itemId !== null) article.dataset.itemId = String(options.itemId);
+ if (options.itemType) article.dataset.itemType = String(options.itemType);
+ if (options.agentThreadId) article.dataset.agentThreadId = String(options.agentThreadId);
+ if (options.timestamp) {
+ const parsedTimestamp = timestampMs(options.timestamp);
+ if (parsedTimestamp !== null) article.dataset.timestamp = String(parsedTimestamp);
+ }
+ article.dataset.rawText = String(text);
+ const content = document.createElement("div");
+ content.className = "message-content";
+ let body = document.createElement("div");
+ body.className = "message-body";
+ let details = null;
+ const initiallyOpen = options.open !== false;
+ if (options.collapsible) {
+ details = document.createElement("details");
+ details.className = `message-details ${options.kind || ""}`;
+ const summary = document.createElement("summary");
+ const summaryText = options.summary || options.label || t("详情");
+ if (options.kind === "tool" || options.kind === "read" || options.kind === "subagent") {
+ setActivitySummary(summary, summaryText, options.kind);
+ if (options.command) summary.title = terminalCommandText(options.command);
+ } else {
+ summary.textContent = summaryText;
+ if (options.command && !options.summary) {
+ // Translate only the renderer-owned label. The command is host data
+ // and must remain byte-for-byte unchanged in every locale.
+ summary.textContent = `${t(options.label || "命令")} · ${String(options.command)}`;
+ summary.title = String(options.command);
+ }
+ }
+ body.classList.add("details-body");
+ details.append(summary, body);
+ content.append(details);
+ } else content.append(body);
+ renderMessageBody(body, text, role, tone, options.kind);
+ article.append(content);
+ const actions = addMessageActions(article, options.showActions !== false);
+ const metaParts = [];
+ if (meta) metaParts.push(String(meta));
+ if (options.showTimestamp === true && options.timestamp) {
+ const timestamp = formatMessageTime(options.timestamp);
+ if (timestamp) metaParts.push(timestamp);
+ }
+ if (options.showDuration === true && options.durationMs !== undefined && options.durationMs !== null) {
+ const duration = formatDuration(options.durationMs);
+ if (duration) metaParts.push(uiWithRaw("用时 ", "Worked for ", duration));
+ }
+ if (metaParts.length) {
+ const stamp = document.createElement("div");
+ stamp.className = "message-meta";
+ stamp.textContent = metaParts.join(" · ");
+ if (actions) actions.prepend(stamp);
+ else content.append(stamp);
+ }
+ output.append(article);
+ if (details) installDisclosure(details, initiallyOpen);
+ if (follow) scrollOutput(output, true);
+ return { article, content: body, wrapper: content };
+ }
+
+ function appendDateSeparator(timestamp, options = {}) {
+ const parsedTimestamp = timestampMs(timestamp);
+ const role = String(options.role || "user");
+ // The official timestamp projection treats an item without a usable time
+ // as an adjacency break. Do not carry the previous role/time across an
+ // untimestamped item, otherwise a later assistant message can inherit an
+ // unrelated 10-minute/1-hour gap.
+ if (parsedTimestamp === null) {
+ state.lastRenderedDateKey = "";
+ state.lastRenderedTimestamp = null;
+ state.lastRenderedRole = "";
+ if (role === "user") state.hasRenderedUser = true;
+ return;
+ }
+ const previousTimestamp = state.lastRenderedTimestamp;
+ const previousRole = state.lastRenderedRole;
+ const hour = 60 * 60 * 1000;
+ const tenMinutes = 10 * 60 * 1000;
+ const gap = previousTimestamp === null ? null : parsedTimestamp - previousTimestamp;
+ // This mirrors the official timestamps projection: a first/next user turn
+ // is separated only after a substantial pause, while consecutive assistant
+ // entries can be separated after a shorter gap.
+ const threshold = previousRole === "assistant"
+ ? role === "user" ? hour : tenMinutes
+ : Infinity;
+ const firstUserIsOld = !state.hasRenderedUser && role === "user"
+ && Date.now() - parsedTimestamp > hour;
+ // The official local composer always gives the first user message a
+ // centered date/time anchor, even when that message was sent today. It is
+ // also the visual boundary that separates a freshly attached history from
+ // the input composer, so do not hide it merely because the turn is recent.
+ const firstUserTurn = !state.hasRenderedUser && role === "user";
+ const show = options.force === true || options.breaksPreviousAdjacency === true || firstUserIsOld
+ || firstUserTurn
+ || (gap !== null && gap > 0 && previousRole === "assistant" && gap > threshold);
+ if (show && state.lastDateSeparatorTimestamp !== parsedTimestamp) {
+ const label = formatMessageDate(parsedTimestamp);
+ if (label) {
+ const separator = document.createElement("div");
+ separator.className = "date-separator";
+ separator.setAttribute("role", "separator");
+ separator.setAttribute("aria-label", label);
+ const time = document.createElement("time");
+ time.dateTime = new Date(parsedTimestamp).toISOString();
+ const splitAt = label.lastIndexOf(" ");
+ if (splitAt > 0 && splitAt < label.length - 1) {
+ const dateLabel = document.createElement("span");
+ dateLabel.className = "date-label";
+ dateLabel.textContent = label.slice(0, splitAt);
+ const timeLabel = document.createElement("span");
+ timeLabel.className = "date-time";
+ timeLabel.textContent = label.slice(splitAt + 1);
+ time.append(dateLabel, " ", timeLabel);
+ } else time.textContent = label;
+ separator.append(time);
+ $("output").append(separator);
+ state.lastDateSeparatorTimestamp = parsedTimestamp;
+ }
+ }
+ state.lastRenderedDateKey = messageDateKey(parsedTimestamp);
+ state.lastRenderedTimestamp = parsedTimestamp;
+ state.lastRenderedRole = role;
+ if (role === "user") state.hasRenderedUser = true;
+ }
+
+ function turnDividerLabel(status, durationMs) {
+ const duration = elapsedDuration(durationMs);
+ const normalized = normalizeActivityStatus(status, "completed");
+ if (normalized === "interrupted") return duration
+ ? uiWithRaw("你在 ", "You stopped after ", duration, " 后停止了", "")
+ : uiText("你停止了工作", "You stopped working");
+ if (normalized === "failed") return duration
+ ? uiWithRaw("执行失败 · ", "Action failed · ", duration)
+ : uiText("执行失败", "Action failed");
+ if (normalized === "inProgress") {
+ const visible = elapsedDuration(durationMs);
+ return visible ? uiWithRaw("用时 ", "Worked for ", visible) : uiText("正在处理", "Working");
+ }
+ return duration ? uiWithRaw("用时 ", "Worked for ", duration) : uiText("已完成", "Completed");
+ }
+
+ // A worked-for disclosure only has meaning when the turn owns at least one
+ // concrete activity row. Keep this cleanup centralized so a transient
+ // status-only row cannot leave an empty divider behind after it is retired.
+ function removeEmptyTurnDivider(turnId) {
+ const key = String(turnId || "");
+ if (!key) return;
+ const output = $("output");
+ if (!output) return;
+ const hasActivity = [...output.querySelectorAll(".message.activity")]
+ .some((entry) => entry.dataset.turnId === key);
+ if (hasActivity) return;
+ const divider = state.turnDividers.get(key)
+ || [...output.querySelectorAll(".turn-divider")]
+ .find((entry) => entry.dataset.turnId === key)
+ || null;
+ if (divider?.parentNode) divider.remove();
+ state.turnDividers.delete(key);
+ }
+
+ function retireActivity(activity) {
+ if (!activity) return;
+ const key = String(activity.key || "");
+ const turnId = String(activity.turnId || "");
+ if (key && state.activities.get(key) === activity) state.activities.delete(key);
+ if (key) state.commandDisclosure.delete(key);
+ if (state.liveActivityKey === key) state.liveActivityKey = null;
+ if (state.activeAssistantActivityKey === key) {
+ state.activeAssistantBody = null;
+ state.activeAssistantStream = null;
+ state.activeAssistantText = "";
+ state.activeAssistantActivityKey = null;
+ }
+ if (activity.article?.parentNode) activity.article.remove();
+ removeEmptyTurnDivider(turnId);
+ stopActivityTimerIfIdle();
+ updateScrollToBottom($("output"));
+ }
+
+ function retireStatusOnlyActivities(turnId = "") {
+ const key = String(turnId || "");
+ for (const activity of [...state.activities.values()]) {
+ if (activity.statusOnly !== true || activity.concrete === true) continue;
+ if (key && activity.turnId && activity.turnId !== key) continue;
+ retireActivity(activity);
+ }
+ }
+
+ function appendTurnDivider(turnId, status = "completed", durationMs, beforeArticle = null, options = {}) {
+ const key = String(turnId || `anonymous-${state.activitySequence}`);
+ const output = $("output");
+ const autoPosition = beforeArticle === null;
+ const turnEntries = [...output.querySelectorAll(".message")]
+ .filter((entry) => entry.dataset.turnId === key);
+ // The official worked-for disclosure owns the activity portion of a turn.
+ // A final assistant answer remains visible below the disclosure; commentary
+ // and concrete work rows are the entries that collapse underneath it.
+ const firstTurnActivity = turnEntries.find((entry) => entry.classList.contains("activity")) || null;
+ // Do not manufacture an empty worked-for row for a final-only turn. This
+ // can happen when completion metadata arrives before any item lifecycle
+ // event, or when a transient status row has just been retired.
+ if (!firstTurnActivity) {
+ removeEmptyTurnDivider(key);
+ return null;
+ }
+ const firstTurnContent = firstTurnActivity
+ || turnEntries.find((entry) => !entry.classList.contains("user"))
+ || null;
+ if (firstTurnContent) beforeArticle = firstTurnContent;
+ else if (autoPosition) beforeArticle = turnEntries.at(-1) || null;
+ let divider = state.turnDividers.get(key);
+ if (!divider) {
+ divider = [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key) || null;
+ }
+ if (!divider) {
+ divider = document.createElement("div");
+ divider.className = "turn-divider";
+ divider.dataset.turnId = key;
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "turn-divider-toggle";
+ const initialStatus = normalizeActivityStatus(status, "completed");
+ const rememberedExpansion = state.turnExpansion.get(key);
+ const initialExpanded = rememberedExpansion !== undefined
+ ? rememberedExpansion
+ : options.defaultExpanded === true || initialStatus === "inProgress";
+ if (rememberedExpansion !== undefined || options.defaultExpanded === true) {
+ button.dataset.userToggled = rememberedExpansion !== undefined ? "true" : "false";
+ }
+ button.setAttribute("aria-expanded", String(initialExpanded));
+ const label = document.createElement("span");
+ label.className = "turn-divider-label";
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ icon.setAttribute("viewBox", "0 0 16 16");
+ icon.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "m6 3 5 5-5 5");
+ icon.append(path);
+ button.append(label, icon);
+ const rule = document.createElement("div");
+ rule.className = "turn-divider-rule";
+ divider.append(button, rule);
+ button.addEventListener("click", () => {
+ const expanded = button.getAttribute("aria-expanded") === "true";
+ const next = !expanded;
+ // Hydration may choose an expanded latest turn for parity with the
+ // official panel. Once the reader explicitly toggles it, preserve
+ // that choice across subsequent structured snapshots.
+ button.dataset.userToggled = "true";
+ state.turnExpansion.set(key, next);
+ button.setAttribute("aria-expanded", String(next));
+ setTurnActivityVisibility(key, next);
+ if (next) {
+ const firstVisibleEntry = [...output.querySelectorAll(".message.activity")]
+ .filter((entry) => entry.dataset.turnId === key)
+ .find((entry) => !entry.classList.contains("turn-collapsed"));
+ if (firstVisibleEntry) scheduleTimelineReveal(firstVisibleEntry, 280);
+ }
+ });
+ if (beforeArticle && beforeArticle.parentNode === output) output.insertBefore(divider, beforeArticle);
+ else output.append(divider);
+ state.turnDividers.set(key, divider);
+ } else if (beforeArticle === false && divider.parentNode === output && output.lastElementChild !== divider) {
+ // An additional activity can arrive after an already-created live
+ // divider. Explicit `false` means place the divider at the current tail.
+ output.append(divider);
+ } else if (beforeArticle && beforeArticle.parentNode === output && divider !== beforeArticle) {
+ // Completion can arrive after the final assistant article. Move the
+ // existing disclosure instead of creating a second one at the tail.
+ output.insertBefore(divider, beforeArticle);
+ }
+ const label = divider.querySelector(".turn-divider-label");
+ if (durationMs !== null && durationMs !== undefined && finiteNumber(durationMs) !== null) {
+ divider.dataset.durationMs = String(Math.max(0, Number(durationMs)));
+ }
+ const storedDuration = finiteNumber(durationMs, divider.dataset.durationMs);
+ const liveDuration = durationMs === null || durationMs === undefined
+ ? key === state.turnId && state.turnWorkStartedAt !== null
+ ? Math.max(0, Date.now() - state.turnWorkStartedAt)
+ : storedDuration
+ : storedDuration;
+ if (label) label.textContent = turnDividerLabel(status, liveDuration);
+ const normalizedStatus = normalizeActivityStatus(status, "completed");
+ divider.dataset.status = normalizedStatus;
+ const toggle = divider.querySelector(".turn-divider-toggle");
+ if (options.defaultExpanded === true && toggle?.dataset.userToggled !== "true") {
+ toggle.dataset.defaultExpanded = "true";
+ }
+ if (normalizedStatus === "inProgress" && toggle?.dataset.userToggled !== "true") {
+ toggle.setAttribute("aria-expanded", "true");
+ }
+ const rememberedExpansion = state.turnExpansion.get(key);
+ // The latest completed turn is expanded on first render in the official
+ // panel. A remembered click always wins, including an explicit collapse.
+ const expanded = rememberedExpansion !== undefined
+ ? rememberedExpansion
+ : toggle?.dataset.userToggled === "true"
+ ? toggle.getAttribute("aria-expanded") === "true"
+ : normalizedStatus === "inProgress" || toggle?.dataset.defaultExpanded === "true";
+ setTurnActivityVisibility(key, expanded);
+ return divider;
+ }
+
+ function appendCompletedTurnDivider(turnId, status = "completed", durationMs) {
+ const key = String(turnId || "");
+ if (!key) return null;
+ const output = $("output");
+ if (!output) return null;
+ const hasTurnMessage = [...output.querySelectorAll(".message")]
+ .some((article) => article.dataset.turnId === key);
+ if (!hasTurnMessage) return null;
+ const hasTurnActivity = [...output.querySelectorAll(".message.activity")]
+ .some((article) => article.dataset.turnId === key);
+ // A final assistant article by itself is not a worked-for group. The
+ // official UI shows its duration in the assistant block without an empty
+ // disclosure row above it.
+ if (!hasTurnActivity) return null;
+ const firstContent = [...output.querySelectorAll(".message")]
+ .find((entry) => entry.dataset.turnId === key && entry.classList.contains("activity"))
+ || [...output.querySelectorAll(".message")]
+ .find((entry) => entry.dataset.turnId === key && !entry.classList.contains("user"))
+ || null;
+ return appendTurnDivider(key, status, durationMs, firstContent);
+ }
+
+ // Structured snapshots can append bookkeeping or collaboration items after
+ // the final assistant item. Reconcile the outer worked-for boundary after
+ // hydration so every activity row remains in the same disclosure group.
+ function reconcileTurnDividers() {
+ const output = $("output");
+ if (!output) return;
+ const turnIds = new Set(
+ [...output.querySelectorAll(".message.activity")]
+ .map((article) => article.dataset.turnId)
+ .filter(Boolean),
+ );
+ for (const key of turnIds) {
+ const activities = [...output.querySelectorAll(".message.activity")]
+ .filter((article) => article.dataset.turnId === key);
+ if (!activities.length) continue;
+ let divider = state.turnDividers.get(key)
+ || [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key)
+ || null;
+ const finalAssistant = [...output.querySelectorAll(".message")]
+ .filter((article) => article.dataset.turnId === key
+ && !article.classList.contains("activity")
+ && article.dataset.kind === "assistant")
+ .at(-1)
+ || null;
+ const activeTurn = key === state.turnId
+ && state.turnStartedAt !== null
+ && ["active", "waiting"].includes(state.turnStatus);
+ if (!divider) {
+ divider = appendTurnDivider(key, activeTurn ? "inProgress" : "completed", state.workedDurationMs, activities[0]);
+ } else {
+ if (activities[0].parentNode === output && divider.parentNode === output && divider !== activities[0]) {
+ output.insertBefore(divider, activities[0]);
+ }
+ const toggle = divider.querySelector(".turn-divider-toggle");
+ if (toggle && toggle.dataset.userToggled !== "true") {
+ const rememberedExpansion = state.turnExpansion.get(key);
+ toggle.setAttribute("aria-expanded", String(rememberedExpansion !== undefined
+ ? rememberedExpansion
+ : activeTurn));
+ }
+ const status = activeTurn ? "inProgress" : normalizeActivityStatus(divider.dataset.status, "completed");
+ divider.dataset.status = status;
+ setTurnActivityVisibility(key, toggle?.getAttribute("aria-expanded") === "true");
+ }
+ // Keep a divider before the activity group even when a final answer was
+ // rendered first. The answer itself intentionally stays outside it.
+ if (divider && activities[0].parentNode === output && divider.nextSibling !== activities[0]) {
+ output.insertBefore(divider, activities[0]);
+ }
+ if (finalAssistant && divider) divider.dataset.hasFinalAssistant = "true";
+ if (finalAssistant) {
+ // Keep trailing bookkeeping/collaboration rows inside the same outer
+ // group. Moving only rows that currently follow the final response
+ // preserves the chronological order of the already-correct prefix.
+ const trailing = activities.filter((activity) => (
+ activity.compareDocumentPosition(finalAssistant) & Node.DOCUMENT_POSITION_PRECEDING
+ ));
+ for (const activity of trailing) output.insertBefore(activity, finalAssistant);
+ // The moves above can carry the first activity past the existing
+ // disclosure. Re-anchor it after every move so the worked-for button
+ // always remains the first node in the activity group.
+ if (activities[0].parentNode === output && divider.parentNode === output) {
+ output.insertBefore(divider, activities[0]);
+ }
+ }
+ }
+ }
+
+ function expandLatestTurnActivity() {
+ const output = $("output");
+ if (!output) return;
+ const dividers = [...output.querySelectorAll(".turn-divider")];
+ const divider = dividers.at(-1);
+ // The latest worked-for group is expanded after a completed snapshot so
+ // the reader can see the activity stream immediately. Older groups stay
+ // compact, and an explicit user toggle always wins.
+ if (!divider) return;
+ const toggle = divider.querySelector(".turn-divider-toggle");
+ if (!toggle || toggle.dataset.userToggled === "true") return;
+ const key = divider.dataset.turnId || "";
+ if (!key || ![...output.querySelectorAll(".message.activity")].some((entry) => entry.dataset.turnId === key)) return;
+ const rememberedExpansion = state.turnExpansion.get(key);
+ const shouldExpand = rememberedExpansion !== undefined
+ ? rememberedExpansion
+ : divider.dataset.status === "inProgress" || divider === dividers.at(-1);
+ if (!shouldExpand) return;
+ if (toggle.getAttribute("aria-expanded") === "true") return;
+ toggle.setAttribute("aria-expanded", "true");
+ setTurnActivityVisibility(key, true);
+ }
+
+ function setTurnActivityVisibility(turnId, expanded) {
+ const key = String(turnId || "");
+ if (!key) return;
+ const output = $("output");
+ if (!output) return;
+ const divider = state.turnDividers.get(key)
+ || [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key)
+ || null;
+ // Keep the final assistant response and its timestamp/actions mounted.
+ // Only rows explicitly marked as activity participate in the outer turn
+ // disclosure, matching the official conversation-blocks split.
+ const activities = [...output.querySelectorAll(".message.activity")]
+ .filter((entry) => entry.dataset.turnId === key);
+ if (!activities.length) return;
+ const knownState = activities.some((activity) => activity.dataset.turnExpanded !== undefined);
+ if (!knownState && !expanded) {
+ for (const activity of activities) {
+ activity.dataset.turnExpanded = "false";
+ animateActivityArticle(activity, false, { immediate: true });
+ }
+ return;
+ }
+ if (divider || activities[0]) preserveTimelineAnchor(divider || activities[0], 280);
+ for (const activity of activities) {
+ const previous = activity.dataset.turnExpanded === "true";
+ activity.dataset.turnExpanded = String(Boolean(expanded));
+ // The outer worked-for disclosure controls every non-user entry. Each
+ // nested command/reasoning disclosure keeps its own state, matching the
+ // official panel where opening a completed turn does not open every
+ // terminal body at once.
+ animateActivityArticle(activity, expanded, {
+ immediate: !knownState && !previous,
+ fromHeight: expanded && !previous ? 0 : undefined,
+ preserve: false,
+ });
+ }
+ if (expanded) revealTerminalOutputs(key);
+ }
+
+ function revealTerminalOutputs(turnId) {
+ const key = String(turnId || "");
+ if (!key) return;
+ requestAnimationFrame(() => {
+ const output = $("output");
+ if (!output) return;
+ for (const activity of output.querySelectorAll(".message.activity")) {
+ if (activity.dataset.turnId !== key) continue;
+ const terminal = activity.querySelector(".terminal-output");
+ if (!terminal || terminal.scrollHeight <= terminal.clientHeight) continue;
+ // Hidden turn sections have no layout while hydrated. Once revealed,
+ // show the newest terminal lines just like the official reverse list.
+ terminal.scrollTop = terminal.scrollHeight;
+ updateTerminalOutputFade(terminal);
+ }
+ });
+ }
+
+ function ensureLiveTurnDivider(turnId) {
+ const key = String(turnId || "");
+ if (!key) return null;
+ const output = $("output");
+ const entries = [...output.querySelectorAll(".message")]
+ .filter((entry) => entry.dataset.turnId === key);
+ const firstContent = entries.find((entry) => entry.classList.contains("activity"))
+ || entries.find((entry) => !entry.classList.contains("user"))
+ || null;
+ if (firstContent) return appendTurnDivider(key, "inProgress", null, firstContent);
+ return appendTurnDivider(key, "inProgress", null, false);
+ }
+
+ const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
+
+ function finiteNumber(...values) {
+ for (const value of values) {
+ if (value === null || value === undefined || value === "" || typeof value === "boolean") continue;
+ const number = Number(value);
+ if (Number.isFinite(number)) return number;
+ }
+ return null;
+ }
+
+ function timestampMs(...values) {
+ let value = null;
+ for (const candidate of values) {
+ if (candidate === null || candidate === undefined || candidate === "" || typeof candidate === "boolean") continue;
+ if (typeof candidate === "string" && candidate.trim() && !/^\d+(?:\.\d+)?$/.test(candidate.trim())) {
+ const parsed = Date.parse(candidate);
+ if (Number.isFinite(parsed)) { value = parsed; break; }
+ }
+ const numeric = Number(candidate);
+ if (Number.isFinite(numeric)) { value = numeric; break; }
+ }
+ if (value === null || value <= 0) return null;
+ // Accept ISO epoch seconds from older followers as well as the current
+ // app-server's millisecond fields.
+ return value < 100_000_000_000 ? value * 1_000 : value;
+ }
+
+ function workedDurationFor(value, fallback = null) {
+ const source = isRecord(value) ? value : {};
+ const nestedTurn = isRecord(source.turn) ? source.turn : {};
+ const nestedStatus = isRecord(source.status) ? source.status : {};
+ const explicit = finiteNumber(
+ source.workedDurationMs,
+ source.workDurationMs,
+ source.workedForMs,
+ source.turnWorkedDurationMs,
+ source.worked_for_ms,
+ nestedTurn.workedDurationMs,
+ nestedTurn.workDurationMs,
+ nestedTurn.workedForMs,
+ nestedStatus.workedDurationMs,
+ nestedStatus.workDurationMs,
+ );
+ if (explicit !== null) return Math.max(0, explicit);
+ const started = timestampMs(
+ source.firstTurnWorkItemStartedAtMs,
+ source.workStartedAtMs,
+ source.turnWorkStartedAtMs,
+ nestedTurn.firstTurnWorkItemStartedAtMs,
+ nestedTurn.workStartedAtMs,
+ nestedTurn.turnStartedAtMs,
+ );
+ const completed = timestampMs(
+ source.finalAssistantStartedAtMs,
+ source.workCompletedAtMs,
+ nestedTurn.finalAssistantStartedAtMs,
+ nestedTurn.workCompletedAtMs,
+ source.completedAtMs,
+ );
+ if (started !== null && completed !== null) return Math.max(0, completed - started);
+ return finiteNumber(fallback);
+ }
+
+ function formatDuration(value) {
+ const duration = finiteNumber(value);
+ if (duration === null || duration < 0) return "";
+ if (uiLocale() === "en-US") {
+ if (duration < 1_000) return `${Math.round(duration)}ms`;
+ const totalSeconds = Math.floor(duration / 1_000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ if (!minutes) return `${seconds}s`;
+ return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
+ }
+ if (duration < 1_000) return `${Math.round(duration)}毫秒`;
+ const totalSeconds = Math.floor(duration / 1_000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ if (!minutes) return `${seconds}秒`;
+ // The Chinese locale in the official panel uses the long form for one
+ // minute and the compact form for longer durations.
+ const minuteLabel = minutes === 1 ? "1分钟" : `${minutes}分`;
+ return seconds ? `${minuteLabel}${seconds}秒` : minuteLabel;
+ }
+
+ // Working indicators in the official panel stay textual until a full
+ // second has elapsed. This avoids the visually noisy "0ms"/"1ms" state on
+ // every newly-created activity row while preserving precise durations once
+ // an operation is complete.
+ function elapsedDuration(value) {
+ const duration = finiteNumber(value);
+ return duration !== null && duration >= 1_000 ? formatDuration(duration) : "";
+ }
+
+ function formatMessageTime(value) {
+ const timestamp = timestampMs(value);
+ if (timestamp === null) return "";
+ try {
+ return new Intl.DateTimeFormat(uiLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(timestamp));
+ } catch {
+ return "";
+ }
+ }
+
+ function formatMessageDate(value, nowValue = Date.now()) {
+ const timestamp = timestampMs(value);
+ if (timestamp === null) return "";
+ try {
+ const date = new Date(timestamp);
+ const now = new Date(nowValue);
+ // Match the official timestamp separator: compare calendar dates while
+ // avoiding DST changes in the local timezone.
+ const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
+ const nowDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
+ const dayDifference = Math.max(0, Math.round((nowDay - dateDay) / 86_400_000));
+ const locale = uiLocale();
+ const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(date);
+ if (dayDifference <= 1) {
+ try {
+ const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-Math.max(dayDifference, 0), "day");
+ return `${relative} ${time}`;
+ } catch {
+ return `${t(dayDifference === 1 ? "昨天" : "今天")} ${time}`;
+ }
+ }
+ if (dayDifference <= 7 && dayDifference > 0) {
+ const weekday = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
+ return `${weekday} ${time}`;
+ }
+ const datePart = dayDifference <= 365
+ ? new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", weekday: "short" }).format(date)
+ : new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" }).format(date);
+ return `${datePart} ${time}`;
+ } catch {
+ return "";
+ }
+ }
+
+ function messageDateKey(value) {
+ const timestamp = timestampMs(value);
+ if (timestamp === null) return "";
+ const date = new Date(timestamp);
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ }
+
+ function normalizeActivityStatus(value, fallback = "inProgress") {
+ const normalized = String(value ?? fallback).replace(/[\s_-]+/g, "").toLowerCase();
+ if (["inprogress", "running", "started", "active", "pending"].includes(normalized)) return "inProgress";
+ if (["completed", "complete", "success", "succeeded", "done"].includes(normalized)) return "completed";
+ if (["failed", "failure", "error"].includes(normalized)) return "failed";
+ if (["declined", "denied", "rejected"].includes(normalized)) return "declined";
+ if (["interrupted", "cancelled", "canceled", "aborted"].includes(normalized)) return "interrupted";
+ return fallback;
+ }
+
+ function activityStatusLabel(status) {
+ if (status === "inProgress") return "进行中";
+ if (status === "completed") return "已完成";
+ if (status === "failed") return "失败";
+ if (status === "declined") return "已拒绝";
+ if (status === "interrupted") return "已中断";
+ return String(status || "");
+ }
+
+ const isRunningActivity = (activity) => activity && activity.status === "inProgress";
+
+ function eventParams(payload) {
+ if (!isRecord(payload)) return {};
+ return isRecord(payload.params) ? payload.params : payload;
+ }
+
+ function eventThreadId(payload) {
+ const params = eventParams(payload);
+ return payload?.threadId || params.threadId || params.thread?.id || params.turn?.threadId || "";
+ }
+
+ function eventTurnId(payload) {
+ const params = eventParams(payload);
+ return payload?.turnId || params.turnId || params.turn?.id || params.item?.turnId || "";
+ }
+
+ function itemFromPayload(payload) {
+ const params = eventParams(payload);
+ if (isRecord(params.item)) return params.item;
+ if (isRecord(payload?.item)) return payload.item;
+ return isRecord(params) && (params.type || params.kind) ? params : {};
+ }
+
+ function normalizedItemType(item) {
+ return String(item?.type || item?.kind || "").replace(/[\s/_.-]+/g, "").toLowerCase();
+ }
+
+ function activityKindForItem(item) {
+ const type = normalizedItemType(item);
+ if (isReadActivity(item)) return "read";
+ if (!type) return "";
+ if (type.includes("subagent") || type.includes("collabagent")) return "subagent";
+ if (type.includes("filechange") || type.includes("patch") || type.includes("edit")) return "edit";
+ if (type.includes("reasoning") || type.includes("contextcompaction")) return "reasoning";
+ if (type.includes("plan") || type.includes("reviewmode")) return "plan";
+ if (type.includes("command") || type.includes("exec") || type.includes("process")) return "tool";
+ if (type.includes("tool") || type.includes("mcp") || type.includes("websearch") || type.includes("imageview")) return "tool";
+ if (type.includes("usermessage")) return "user";
+ if (type.includes("agentmessage") || type.includes("assistantmessage")) return "assistant";
+ return "";
+ }
+
+ function activityLabelForItem(item, kind) {
+ const type = normalizedItemType(item);
+ if (kind === "subagent" || type.includes("subagent") || type.includes("collabagent")) {
+ return firstString(item.displayName, item.agentPath, item.action) || "子代理";
+ }
+ if (type.includes("contextcompaction")) return "整理上下文";
+ if (type.includes("websearch")) return "搜索";
+ if (type.includes("imageview")) return "查看图像";
+ if (type.includes("mcp")) return "MCP 工具";
+ if (type.includes("dynamictool")) return "工具";
+ if (kind === "edit") return "编辑文件";
+ if (kind === "read") return "读取文件";
+ if (kind === "plan") return "计划";
+ if (kind === "reasoning") return "思考";
+ if (kind === "commentary") return "工作说明";
+ if (kind === "tool") return "运行命令";
+ return "执行步骤";
+ }
+
+ function historyActivitySummary(item, kind, status, duration) {
+ const elapsed = duration ? ` · ${duration}` : "";
+ if (kind === "subagent") {
+ const name = firstString(item.displayName, item.agentPath, item.agentThreadId ? `thread ${item.agentThreadId}` : "") || t("子代理");
+ const uiStatus = firstString(item.displayStatus, item.activityKind);
+ if (status === "inProgress" || uiStatus === "active" || uiStatus === "updated") return `${name} ${t("已开始工作")}`;
+ if (status === "failed") return `${name} ${t("失败")}`;
+ if (status === "interrupted") return `${name} ${t("已中断")}`;
+ return `${name} ${t("已完成")}`;
+ }
+ if (kind === "tool" || kind === "read") {
+ const command = terminalCommandText(commandText(item));
+ const actionSource = String(item.label || "工具");
+ const action = t(actionSource);
+ const path = firstString(...readPathList(item));
+ if (kind === "read" && status === "inProgress") return readSummaryLabel(item, status);
+ if (kind === "read" && !command) {
+ return readSummaryLabel(item, status);
+ }
+ if (kind === "read" && status !== "inProgress") {
+ // Parsed read/search actions are coalesced by the official renderer
+ // into one compact exploration row rather than a path plus a second
+ // timed command row.
+ if (command) return status === "failed" ? t("读取文件运行命令失败") : status === "interrupted" ? t("已停止读取文件运行命令") : t("已读取文件运行了命令");
+ return readSummaryLabel(item, status);
+ }
+ if (status === "inProgress") return command
+ ? uiWithRaw("正在运行 ", "Running ", command)
+ : uiLocale() === "en-US" ? `Running ${action}` : `正在${action}`;
+ if (status === "failed") return command
+ ? `${uiWithRaw("命令运行失败 · ", "Command failed · ", command)}${elapsed}`
+ : `${action}${uiText("失败", " failed")}${elapsed}`;
+ if (status === "interrupted") return command
+ ? `${uiWithRaw("已停止 ", "Stopped ", command)}${elapsed}`
+ : `${uiText("已停止", "Stopped ")}${action}${elapsed}`;
+ if (command) return duration
+ ? `${uiWithRaw("已在 ", "Ran ", command, " 内运行 ", " in ")}${duration}`
+ : uiWithRaw("已运行 ", "Ran ", command);
+ return uiLocale() === "en-US"
+ ? `Ran ${action}${elapsed}`
+ : `已${action}${elapsed}`;
+ }
+ if (kind === "edit") return status === "inProgress" ? t("正在编辑文件") : `${t("编辑了文件")}${elapsed}`;
+ if (kind === "reasoning") return status === "inProgress" ? t("正在思考") : duration ? uiWithRaw("已思考 ", "Thought for ", duration) : t("已完成思考");
+ if (kind === "plan") return status === "inProgress" ? t("正在制定计划") : `${t("已完成计划")}${elapsed}`;
+ if (kind === "commentary") {
+ if (status === "inProgress") return t("正在处理");
+ return t("工作说明");
+ }
+ return "";
+ }
+
+ function textFromValue(value, depth = 0) {
+ if (typeof value === "string") return value;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ if (Array.isArray(value)) {
+ const parts = value.map((entry) => textFromValue(entry, depth + 1)).filter(Boolean);
+ return parts.length ? parts.join("\n") : "";
+ }
+ if (!isRecord(value) || depth > 3) return "";
+ for (const key of ["text", "value", "output", "stdout", "stderr", "delta", "summary", "message"]) {
+ const text = textFromValue(value[key], depth + 1);
+ if (text) return text;
+ }
+ return "";
+ }
+
+ function displayValue(value) {
+ const text = textFromValue(value);
+ if (text) return text;
+ if (value === undefined || value === null) return "";
+ try { return JSON.stringify(value, null, 2); } catch { return String(value); }
+ }
+
+ function commandText(item) {
+ if (Array.isArray(item.command)) return item.command.map(String).join(" ");
+ if (typeof item.command === "string") return item.command;
+ if (typeof item.commandLine === "string") return item.commandLine;
+ if (Array.isArray(item.commandActions)) {
+ return item.commandActions
+ .map((action) => isRecord(action) ? action.command || action.description || "" : "")
+ .filter(Boolean)
+ .join("\n");
+ }
+ return "";
+ }
+
+ function fileChangesText(changes) {
+ if (!Array.isArray(changes)) return displayValue(changes);
+ return changes.map((change) => {
+ if (!isRecord(change)) return displayValue(change);
+ const path = change.path || change.file || change.filePath || change.name || "文件";
+ const kind = change.kind || change.type || change.status || "";
+ const diff = displayValue(change.diff || change.patch || change.output || change.text);
+ const heading = `${kind ? `[${kind}] ` : ""}${path}`;
+ return diff ? `${heading}\n${diff}` : heading;
+ }).filter(Boolean).join("\n\n");
+ }
+
+ function planText(value) {
+ const plan = Array.isArray(value) ? value : value?.plan || value?.steps;
+ if (!Array.isArray(plan)) return displayValue(value?.text || value);
+ return plan.map((entry) => {
+ if (!isRecord(entry)) return `- ${displayValue(entry)}`;
+ const status = normalizeActivityStatus(entry.status, "pending");
+ const marker = status === "completed" ? "[x]" : status === "inProgress" ? "[~]" : "[ ]";
+ const step = entry.step || entry.text || entry.title || entry.description || "步骤";
+ return `${marker} ${step}`;
+ }).join("\n");
+ }
+
+ function activityHeader(item, kind) {
+ // Terminal activities render command/cwd as separate fields below. Keep
+ // this helper for non-terminal activity kinds and future item types.
+ return "";
+ }
+
+ function activityOutput(item, kind) {
+ if (kind === "reasoning") return displayValue(item.summary || item.content || item.text);
+ if (kind === "plan") return planText(item.plan || item.steps || item.content || item.text);
+ if (kind === "edit") return fileChangesText(item.changes || item.files || item.diff || item.patch || item.output || item.text);
+ if (kind === "tool" || kind === "read") {
+ if (kind === "read") {
+ // Paths are rendered as their own compact exploration rows. Keep only
+ // the actual file content here; adapter summaries such as
+ // "已读取 ..." must not be repeated inside a Shell block.
+ let value = displayValue(item.aggregatedOutput ?? item.output ?? item.stdout ?? item.stderr ?? item.result);
+ const summary = firstString(item.text, item.summary);
+ if (summary && value === summary) return "";
+ if (summary && value.startsWith(`${summary}\n`)) value = value.slice(summary.length + 1);
+ return value;
+ }
+ return displayValue(item.aggregatedOutput ?? item.output ?? item.stdout ?? item.stderr ?? item.result ?? item.error ?? item.text);
+ }
+ return displayValue(item.text || item.content || item.output);
+ }
+
+ function activityKey(payload, item, kind, explicitKey) {
+ if (explicitKey) return explicitKey;
+ const params = eventParams(payload);
+ const id = item.id ?? params.itemId ?? payload?.itemId;
+ const threadId = eventThreadId(payload) || state.threadId || "thread";
+ const turnId = eventTurnId(payload) || state.turnId || "turn";
+ if (id !== undefined && id !== null) return `${threadId}:${turnId}:${kind}:${typeof id}:${String(id)}`;
+ state.activitySequence += 1;
+ return `${threadId}:${turnId}:${kind}:anonymous:${state.activitySequence}`;
+ }
+
+ function ensureActivity(key, config = {}) {
+ const kind = config.kind || "reasoning";
+ let existing = state.activities.get(key);
+ if (!existing) {
+ const requestedItemId = config.itemId === undefined || config.itemId === null ? "" : String(config.itemId);
+ const sameTurn = (entry) => entry.kind === kind
+ && (!config.turnId || !entry.turnId || entry.turnId === String(config.turnId));
+ const entries = [...state.activities.entries()].reverse();
+ const exact = requestedItemId
+ ? entries.find(([, entry]) => sameTurn(entry) && entry.itemId === requestedItemId)
+ : null;
+ // Status snapshots may arrive before the item lifecycle notification.
+ // When the concrete item appears, adopt the still-running anonymous row
+ // instead of appending a second "正在思考/读取/编辑" entry.
+ const anonymous = entries.find(([, entry]) => sameTurn(entry)
+ && entry.anonymous
+ && isRunningActivity(entry));
+ const unkeyed = !requestedItemId
+ ? entries.find(([, entry]) => sameTurn(entry) && !entry.itemId && isRunningActivity(entry))
+ : null;
+ const match = exact || anonymous || unkeyed;
+ if (match) {
+ const [oldKey, candidate] = match;
+ existing = candidate;
+ if (oldKey !== key) {
+ state.activities.delete(oldKey);
+ existing.key = key;
+ existing.article.dataset.activityKey = key;
+ if (state.liveActivityKey === oldKey) state.liveActivityKey = key;
+ if (state.activeAssistantActivityKey === oldKey) state.activeAssistantActivityKey = key;
+ }
+ if (requestedItemId && existing.anonymous) {
+ existing.itemId = requestedItemId;
+ existing.anonymous = false;
+ }
+ state.activities.set(key, existing);
+ }
+ }
+ if (existing) {
+ // A lifecycle event or real output upgrades a transient status row into
+ // a concrete activity. Once upgraded, it must survive turn completion;
+ // status-only rows are retired when the host reports a terminal state.
+ if (config.concrete === true) {
+ existing.concrete = true;
+ existing.statusOnly = false;
+ // Keep the anonymous marker for id-less lifecycle streams so the
+ // terminal status path can still close them when the host omits an
+ // explicit item/completed event. An identified item is authoritative.
+ if (config.itemId !== undefined && config.itemId !== null && String(config.itemId)) {
+ existing.anonymous = false;
+ }
+ } else if (config.statusOnly === true && existing.concrete !== true) {
+ existing.statusOnly = true;
+ }
+ if (config.label) existing.label = config.label;
+ if (config.itemId !== undefined) existing.itemId = String(config.itemId);
+ if (config.command) existing.command = kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command);
+ if (config.filePath !== undefined) existing.filePath = String(config.filePath || "");
+ if (config.cwd !== undefined) existing.cwd = String(config.cwd || "");
+ if (config.shellName) existing.shellName = String(config.shellName);
+ if (config.agentThreadId !== undefined) existing.agentThreadId = String(config.agentThreadId || "");
+ if (config.displayName !== undefined) existing.displayName = String(config.displayName || "");
+ if (config.objective !== undefined) existing.objective = String(config.objective || "");
+ if (config.activityKind !== undefined) existing.activityKind = String(config.activityKind || "");
+ if (config.displayStatus !== undefined) existing.displayStatus = String(config.displayStatus || "");
+ if (config.model !== undefined) existing.model = String(config.model || "");
+ if (config.action !== undefined) existing.action = String(config.action || "");
+ if (config.prompt !== undefined) existing.prompt = String(config.prompt || "");
+ if (config.senderThreadId !== undefined) existing.senderThreadId = String(config.senderThreadId || "");
+ if (config.receiverThreadIds !== undefined) existing.receiverThreadIds = Array.isArray(config.receiverThreadIds) ? config.receiverThreadIds.map(String) : [];
+ if (config.agentsStates !== undefined) existing.agentsStates = isRecord(config.agentsStates) ? config.agentsStates : {};
+ if (config.canInteract !== undefined) existing.canInteract = config.canInteract !== false;
+ if (config.exitCode !== undefined) existing.exitCode = finiteNumber(config.exitCode);
+ if (config.turnId) existing.turnId = String(config.turnId);
+ if (config.startedAt) existing.startedAt = timestampMs(config.startedAt) || existing.startedAt;
+ if (existing.agentThreadId) existing.article.dataset.agentThreadId = existing.agentThreadId;
+ else delete existing.article.dataset.agentThreadId;
+ if (kind === "tool" || kind === "read" || kind === "subagent" || kind === "commentary") renderActivityText(existing);
+ refreshActivity(existing);
+ if (existing.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)) {
+ ensureLiveTurnDivider(existing.turnId);
+ }
+ return existing;
+ }
+ const role = kind === "commentary"
+ ? "assistant"
+ : kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" ? "tool" : "system";
+ const messageKind = kind === "tool" || kind === "read" ? "tool" : kind === "reasoning" ? "reasoning" : kind === "plan" ? "plan" : kind;
+ const message = appendMessage("", role, "activity", "", {
+ kind: messageKind,
+ label: config.label || "执行步骤",
+ command: config.command ? (kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command)) : "",
+ turnId: config.turnId || "",
+ agentThreadId: config.agentThreadId || "",
+ // Commentary is an assistant paragraph in the official transcript, not
+ // a nested disclosure. The outer worked-for group still owns its layout.
+ collapsible: kind !== "commentary",
+ showActions: false,
+ // Keep commentary visible while a turn is open; command/read/reasoning
+ // bodies remain independently collapsible.
+ open: config.open === true || kind === "commentary",
+ });
+ if (!message) return null;
+ const activity = {
+ key,
+ kind,
+ role,
+ messageKind,
+ label: config.label || "执行步骤",
+ command: config.command ? (kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command)) : "",
+ cwd: config.cwd ? String(config.cwd) : "",
+ shellName: config.shellName ? String(config.shellName) : "Shell",
+ agentThreadId: config.agentThreadId ? String(config.agentThreadId) : "",
+ displayName: config.displayName ? String(config.displayName) : "",
+ objective: config.objective ? String(config.objective) : "",
+ activityKind: config.activityKind ? String(config.activityKind) : "",
+ displayStatus: config.displayStatus ? String(config.displayStatus) : "",
+ model: config.model ? String(config.model) : "",
+ action: config.action ? String(config.action) : "",
+ filePath: config.filePath ? String(config.filePath) : "",
+ prompt: config.prompt ? String(config.prompt) : "",
+ senderThreadId: config.senderThreadId ? String(config.senderThreadId) : "",
+ receiverThreadIds: Array.isArray(config.receiverThreadIds) ? config.receiverThreadIds.map(String) : [],
+ agentsStates: isRecord(config.agentsStates) ? config.agentsStates : {},
+ canInteract: config.canInteract !== false,
+ exitCode: config.exitCode === undefined ? null : finiteNumber(config.exitCode),
+ itemId: config.itemId === undefined ? "" : String(config.itemId),
+ threadId: config.threadId || state.threadId || "",
+ turnId: config.turnId || state.turnId || "",
+ startedAt: timestampMs(config.startedAt) || Date.now(),
+ finishedAt: null,
+ durationMs: null,
+ durationExplicit: false,
+ status: normalizeActivityStatus(config.status, "inProgress"),
+ headerText: "",
+ outputText: "",
+ anonymous: Boolean(config.anonymous),
+ concrete: config.concrete === true,
+ statusOnly: config.statusOnly === true,
+ article: message.article,
+ body: message.content,
+ wrapper: message.wrapper,
+ details: message.article.querySelector("details"),
+ summary: message.article.querySelector("summary"),
+ };
+ activity.article.dataset.activityKey = key;
+ activity.article.dataset.activityKind = kind;
+ if (activity.agentThreadId) activity.article.dataset.agentThreadId = activity.agentThreadId;
+ state.activities.set(key, activity);
+ while (state.activities.size > 500) {
+ const removable = [...state.activities].find(([, entry]) => !isRunningActivity(entry));
+ if (!removable) break;
+ state.activities.delete(removable[0]);
+ }
+ renderActivityText(activity);
+ refreshActivity(activity);
+ if (activity.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)) {
+ ensureLiveTurnDivider(activity.turnId);
+ }
+ ensureActivityTimer();
+ return activity;
+ }
+
+ function activityText(activity) {
+ if (activity.kind === "tool" || activity.kind === "read") {
+ const command = terminalCommandText(activity.command);
+ const commandLine = command ? `$ ${command}` : "";
+ return [commandLine, activity.outputText].filter(Boolean).join("\n");
+ }
+ if (activity.kind === "subagent") {
+ const name = activity.displayName || activity.label || t("子代理");
+ const objective = activity.objective || activity.outputText;
+ return [name, objective].filter(Boolean).join("\n");
+ }
+ if (activity.headerText && activity.outputText) return `${activity.headerText}\n\n${activity.outputText}`;
+ return activity.headerText || activity.outputText || "";
+ }
+
+ function normalizeSubagentActionStatus(value) {
+ const normalized = String(value || "").replace(/[\s_-]+/g, "").toLowerCase();
+ if (["pendinginit", "pending", "waiting"].includes(normalized)) return "waiting";
+ if (["running", "working", "active", "started", "interacted", "updated", "inprogress"].includes(normalized)) return "working";
+ if (["completed", "complete", "done", "interrupted", "shutdown"].includes(normalized)) return "done";
+ if (["errored", "error", "failed", "notfound"].includes(normalized)) return "failed";
+ return "waiting";
+ }
+
+ function renderSubagentBody(activity) {
+ const body = activity?.body;
+ if (!body) return;
+ body.replaceChildren();
+ body.classList.remove("terminal-body", "diff-body");
+ body.classList.add("subagent-body");
+
+ const prompt = firstString(activity.prompt, activity.objective, activity.outputText);
+ if (prompt) {
+ const promptNode = document.createElement("div");
+ promptNode.className = "subagent-prompt markdown-body";
+ renderMarkdown(promptNode, prompt);
+ body.append(promptNode);
+ }
+ if (activity.model || activity.action) {
+ const meta = document.createElement("div");
+ meta.className = "subagent-action-meta";
+ if (activity.action) {
+ const action = document.createElement("span");
+ action.textContent = activity.action === "spawnAgent" ? t("启动子代理")
+ : activity.action === "sendInput" ? t("发送输入")
+ : activity.action === "resumeAgent" ? t("恢复子代理")
+ : activity.action === "closeAgent" ? t("关闭子代理") : activity.action;
+ meta.append(action);
+ }
+ if (activity.model) {
+ const model = document.createElement("span");
+ model.className = "subagent-model";
+ model.textContent = activity.model;
+ meta.append(model);
+ }
+ body.append(meta);
+ }
+
+ const states = isRecord(activity.agentsStates) ? activity.agentsStates : {};
+ const receiverIds = Array.isArray(activity.receiverThreadIds) ? activity.receiverThreadIds : [];
+ const ids = [...new Set([...receiverIds, ...Object.keys(states)])].filter(Boolean);
+ if (!ids.length) return;
+ const rows = document.createElement("div");
+ rows.className = "subagent-action-rows";
+ for (const threadId of ids) {
+ const raw = isRecord(states[threadId]) ? states[threadId] : {};
+ const status = normalizeSubagentActionStatus(raw.status);
+ const row = document.createElement("div");
+ row.className = "subagent-action-row";
+ row.dataset.status = status;
+ const icon = document.createElement("span");
+ icon.className = "subagent-action-icon";
+ icon.setAttribute("aria-hidden", "true");
+ const label = document.createElement("span");
+ label.className = "subagent-action-label";
+ label.textContent = threadId === activity.agentThreadId
+ ? firstString(activity.displayName, threadId)
+ : `thread ${threadId}`;
+ const statusNode = document.createElement("span");
+ statusNode.className = "subagent-action-status";
+ statusNode.textContent = subagentStatusLabel(status);
+ row.append(icon, label, statusNode);
+ const message = firstString(raw.message, raw.statusMessage);
+ if (message) {
+ const note = document.createElement("div");
+ note.className = "subagent-action-note";
+ note.textContent = message;
+ row.append(note);
+ }
+ rows.append(row);
+ }
+ body.append(rows);
+ }
+
+ function terminalOutputText(activity) {
+ const value = String(activity?.outputText || "");
+ // A few older bridge payloads included an exit-code suffix in the output
+ // string. Strip only that exact synthetic line; real command output stays
+ // untouched.
+ return normalizeTerminalOutput(value.replace(/\n?exit code:\s*-?\d+\s*$/i, ""));
+ }
+
+ function normalizeTerminalOutput(value) {
+ // Commands often use carriage returns/backspaces for progress updates and
+ // ANSI SGR sequences for color. Resolve the control characters before the
+ // lightweight renderer turns the result into safe DOM nodes.
+ const stripped = String(value || "")
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
+ .replace(/\x1b\[(?![0-9;]*m)[0-?]*[ -/]*[@-~]/g, "");
+ return stripped.replace(/\r\n/g, "\n").split("\n").map((line) => {
+ const cells = [];
+ let cursor = 0;
+ for (const character of line) {
+ if (character === "\r") { cursor = 0; continue; }
+ if (character === "\b") { cursor = Math.max(0, cursor - 1); continue; }
+ cells[cursor] = character;
+ cursor += 1;
+ }
+ return cells.join("");
+ }).join("\n");
+ }
+
+ function renderTerminalOutput(parent, value) {
+ parent.replaceChildren();
+ const text = String(value || "");
+ const sgr = /\x1b\[([0-9;]*)m/g;
+ let cursor = 0;
+ const style = { fg: "", bg: "", bold: false, dim: false, italic: false, underline: false, strike: false };
+ const appendSegment = (segment) => {
+ if (!segment) return;
+ const classes = [];
+ if (style.fg) classes.push(`ansi-${style.fg}-fg`);
+ if (style.bg) classes.push(`ansi-${style.bg}-bg`);
+ if (style.bold) classes.push("ansi-bold");
+ if (style.dim) classes.push("ansi-dim");
+ if (style.italic) classes.push("ansi-italic");
+ if (style.underline) classes.push("ansi-underline");
+ if (style.strike) classes.push("ansi-strikethrough");
+ if (!classes.length) parent.append(document.createTextNode(segment));
+ else {
+ const span = document.createElement("span");
+ span.className = classes.join(" ");
+ span.textContent = segment;
+ parent.append(span);
+ }
+ };
+ const applySgr = (codes) => {
+ const values = codes.length ? codes : [0];
+ for (const code of values) {
+ if (code === 0) Object.assign(style, { fg: "", bg: "", bold: false, dim: false, italic: false, underline: false, strike: false });
+ else if (code === 1) style.bold = true;
+ else if (code === 2) style.dim = true;
+ else if (code === 3) style.italic = true;
+ else if (code === 4) style.underline = true;
+ else if (code === 9) style.strike = true;
+ else if (code === 22) { style.bold = false; style.dim = false; }
+ else if (code === 23) style.italic = false;
+ else if (code === 24) style.underline = false;
+ else if (code === 29) style.strike = false;
+ else if (code === 39) style.fg = "";
+ else if (code === 49) style.bg = "";
+ else if (code >= 30 && code <= 37) style.fg = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"][code - 30];
+ else if (code >= 90 && code <= 97) style.fg = ["bright-black", "bright-red", "bright-green", "bright-yellow", "bright-blue", "bright-magenta", "bright-cyan", "bright-white"][code - 90];
+ else if (code >= 40 && code <= 47) style.bg = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"][code - 40];
+ else if (code >= 100 && code <= 107) style.bg = ["bright-black", "bright-red", "bright-green", "bright-yellow", "bright-blue", "bright-magenta", "bright-cyan", "bright-white"][code - 100];
+ }
+ };
+ for (const match of text.matchAll(sgr)) {
+ appendSegment(text.slice(cursor, match.index));
+ applySgr(match[1] ? match[1].split(";").map((part) => Number(part) || 0) : [0]);
+ cursor = match.index + match[0].length;
+ }
+ appendSegment(text.slice(cursor));
+ }
+
+ function updateTerminalOutputFade(output) {
+ if (!output) return;
+ const overflow = output.scrollHeight - output.clientHeight;
+ output.dataset.fadeTop = String(output.scrollTop > 1);
+ output.dataset.fadeBottom = String(overflow - output.scrollTop > 1);
+ }
+
+ function terminalStatusText(activity) {
+ if (!activity) return "";
+ // File-read rows do not have a process exit code. The official renderer
+ // closes them with the read summary, not a misleading "unknown" status.
+ if (activity.kind === "read" && (activity.exitCode === null || activity.exitCode === undefined)) return "";
+ if (activity.status === "inProgress") return "";
+ if (activity.status === "interrupted") return t("已停止");
+ if (activity.status === "failed" || activity.status === "declined") {
+ return activity.exitCode === null || activity.exitCode === undefined
+ ? t("退出码 未知")
+ : t(`退出码 ${activity.exitCode}`);
+ }
+ if (activity.status === "completed") {
+ if (activity.exitCode === 0) return t("成功");
+ if (activity.exitCode !== null && activity.exitCode !== undefined) return t(`退出码 ${activity.exitCode}`);
+ return t("退出码 未知");
+ }
+ return "";
+ }
+
+ function appendTerminalCheckIcon(parent) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.className.baseVal = "terminal-status-icon";
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3");
+ svg.append(path);
+ parent.append(svg);
+ }
+
+ function setTerminalActionIcon(button, kind = "copy") {
+ if (!button) return;
+ button.replaceChildren();
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("aria-hidden", "true");
+ if (kind === "check") {
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3");
+ svg.append(path);
+ } else if (kind === "collapse" || kind === "expand") {
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", kind === "collapse" ? "m5 6 3 3 3-3" : "m5 10 3-3 3 3");
+ svg.append(path);
+ } else {
+ const back = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ back.setAttribute("d", "M5.5 5.5V4c0-.8.7-1.5 1.5-1.5h5c.8 0 1.5.7 1.5 1.5v5c0 .8-.7 1.5-1.5 1.5h-1.5");
+ const front = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ front.setAttribute("x", "2.5");
+ front.setAttribute("y", "5.5");
+ front.setAttribute("width", "7.5");
+ front.setAttribute("height", "7.5");
+ front.setAttribute("rx", "1.2");
+ svg.append(back, front);
+ }
+ button.append(svg);
+ }
+
+ function createTerminalAction(kind, title) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "terminal-action";
+ button.title = t(title);
+ button.setAttribute("aria-label", t(title));
+ setTerminalActionIcon(button, kind);
+ return button;
+ }
+
+ async function copyTerminalValue(value, button) {
+ try {
+ await navigator.clipboard?.writeText(String(value || ""));
+ setTerminalActionIcon(button, "check");
+ button.dataset.copied = "true";
+ setTimeout(() => {
+ if (!button.isConnected) return;
+ setTerminalActionIcon(button, "copy");
+ button.dataset.copied = "false";
+ }, 1_500);
+ } catch {
+ button.dataset.copied = "false";
+ }
+ }
+
+ function createReadPathIcon() {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.className.baseVal = "read-path-icon";
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("aria-hidden", "true");
+ const folder = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ folder.setAttribute("d", "M2.5 4.5h3l1.2 1.4h6.8v6.2a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1z");
+ const top = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ top.setAttribute("d", "M2.5 4.5v-1h3l1.1 1h5.9");
+ svg.append(folder, top);
+ return svg;
+ }
+
+ function renderReadBody(activity) {
+ const body = activity?.body;
+ if (!body) return;
+ body.replaceChildren();
+ body.classList.remove("terminal-body", "diff-body", "markdown-body");
+ body.classList.add("read-body");
+ const paths = String(activity.filePath || "").split("\n").map((value) => value.trim()).filter(Boolean);
+ if (paths.length) {
+ const list = document.createElement("div");
+ list.className = "read-path-list";
+ for (const path of paths) {
+ const row = document.createElement("div");
+ row.className = "read-path-row";
+ row.append(createReadPathIcon());
+ const value = document.createElement("span");
+ value.textContent = path;
+ value.title = path;
+ row.append(value);
+ list.append(row);
+ }
+ body.append(list);
+ }
+ const output = terminalOutputText(activity);
+ if (output) {
+ const outputBlock = document.createElement("pre");
+ outputBlock.className = "read-output";
+ outputBlock.textContent = output;
+ body.append(outputBlock);
+ }
+ if (!paths.length && !output) {
+ const empty = document.createElement("span");
+ empty.className = "read-empty";
+ empty.textContent = t(activity.status === "inProgress" ? "正在读取文件" : "读取完成");
+ body.append(empty);
+ }
+ }
+
+ function renderTerminalBody(activity) {
+ const body = activity?.body;
+ if (!body) return;
+ if (activity.kind === "read" && !terminalCommandText(activity.command)) {
+ renderReadBody(activity);
+ return;
+ }
+ const previousOutput = body.querySelector(".terminal-output");
+ const previousScrollTop = previousOutput?.scrollTop || 0;
+ const previousScrollLeft = previousOutput?.scrollLeft || 0;
+ const previousAtBottom = previousOutput
+ ? previousOutput.scrollHeight - previousOutput.scrollTop - previousOutput.clientHeight <= 2
+ : true;
+ body.replaceChildren();
+ body.classList.add("terminal-body");
+ body.classList.remove("markdown-body", "diff-body");
+
+ const shell = document.createElement("div");
+ shell.className = "terminal-shell";
+ const command = terminalCommandText(activity.command);
+ const output = terminalOutputText(activity);
+
+ const shellHeader = document.createElement("div");
+ shellHeader.className = "terminal-shell-header";
+ const shellLabel = document.createElement("div");
+ shellLabel.className = "terminal-shell-label";
+ shellLabel.textContent = activity.shellName || "Shell";
+ if (activity.cwd) shellLabel.title = `cwd\\n${activity.cwd}`;
+ // The official embedded shell uses a lightweight label row. The parent
+ // activity disclosure owns collapse; command and output each expose their
+ // own copy action on hover.
+ shellHeader.append(shellLabel);
+ shell.append(shellHeader);
+
+ if (activity.kind === "read" && activity.filePath && !command) {
+ const pathRow = document.createElement("div");
+ pathRow.className = "terminal-file-path";
+ const paths = activity.filePath.split("\n").filter(Boolean);
+ pathRow.textContent = paths.length > 1 ? `${paths[0]} (+${paths.length - 1})` : paths[0];
+ pathRow.title = activity.filePath;
+ shell.append(pathRow);
+ }
+
+ if (command) {
+ const commandRow = document.createElement("div");
+ commandRow.className = "terminal-command-line";
+ const prompt = document.createElement("span");
+ prompt.className = "terminal-prompt";
+ prompt.textContent = "$";
+ const commandCode = document.createElement("code");
+ commandCode.textContent = command;
+ const commandChevron = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ commandChevron.classList.add("terminal-command-chevron");
+ commandChevron.setAttribute("viewBox", "0 0 16 16");
+ commandChevron.setAttribute("aria-hidden", "true");
+ const commandChevronPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ commandChevronPath.setAttribute("d", "m4.5 6 3.5 3.5L11.5 6");
+ commandChevron.append(commandChevronPath);
+ const commandExpanded = activity.commandExpanded === true
+ || state.commandDisclosure.get(activity.key) === true;
+ commandRow.dataset.expanded = String(commandExpanded);
+ commandRow.setAttribute("role", "button");
+ commandRow.setAttribute("tabindex", "0");
+ commandRow.setAttribute("aria-expanded", String(commandExpanded));
+ commandRow.setAttribute("aria-label", `$ ${command}`);
+ const toggleCommand = (event) => {
+ if (event.target.closest(".terminal-action")) return;
+ event.preventDefault();
+ const expanded = commandRow.dataset.expanded === "true";
+ const next = !expanded;
+ activity.commandExpanded = next;
+ if (activity.key) state.commandDisclosure.set(activity.key, next);
+ preserveTimelineAnchor(commandRow, 210);
+ animateCommandRow(commandRow, next);
+ if (next) scheduleTimelineReveal(commandRow, 190);
+ };
+ commandRow.addEventListener("click", toggleCommand);
+ commandRow.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") toggleCommand(event);
+ });
+ const copyCommand = createTerminalAction("copy", "复制命令");
+ copyCommand.classList.add("terminal-command-action");
+ copyCommand.addEventListener("click", (event) => {
+ event.stopPropagation();
+ copyTerminalValue(command, copyCommand);
+ });
+ commandRow.append(prompt, commandCode, commandChevron, copyCommand);
+ shell.append(commandRow);
+ }
+
+ const outputWrap = document.createElement("div");
+ outputWrap.className = "terminal-output-wrap";
+ if (output) {
+ const outputBlock = document.createElement("pre");
+ outputBlock.className = "terminal-output";
+ const outputContent = document.createElement("div");
+ outputContent.className = "terminal-output-content";
+ renderTerminalOutput(outputContent, output);
+ outputBlock.append(outputContent);
+ // Activity deltas rebuild the lightweight DOM. Preserve the reader's
+ // position, while still following the tail when they were already at
+ // the bottom of the terminal stream.
+ outputBlock.scrollLeft = previousScrollLeft;
+ requestAnimationFrame(() => {
+ if (previousAtBottom) outputBlock.scrollTop = outputBlock.scrollHeight;
+ else outputBlock.scrollTop = previousScrollTop;
+ outputBlock.scrollLeft = previousScrollLeft;
+ updateTerminalOutputFade(outputBlock);
+ });
+ outputBlock.addEventListener("scroll", () => updateTerminalOutputFade(outputBlock), { passive: true });
+ const copyOutput = createTerminalAction("copy", "复制输出");
+ copyOutput.classList.add("terminal-output-action");
+ copyOutput.addEventListener("click", (event) => {
+ event.stopPropagation();
+ copyTerminalValue(output, copyOutput);
+ });
+ outputWrap.append(outputBlock, copyOutput);
+ } else if (activity.status !== "inProgress") {
+ const outputBlock = document.createElement("pre");
+ outputBlock.className = "terminal-output terminal-output-empty";
+ const outputContent = document.createElement("div");
+ outputContent.className = "terminal-output-content terminal-no-output";
+ outputContent.textContent = t("无输出");
+ outputBlock.append(outputContent);
+ outputWrap.append(outputBlock);
+ }
+ shell.append(outputWrap);
+
+ const statusText = terminalStatusText(activity);
+ const footer = document.createElement("div");
+ footer.className = "terminal-footer";
+ footer.dataset.status = activity.status;
+ if (statusText) {
+ const status = document.createElement("span");
+ status.className = "terminal-status";
+ if (activity.status === "completed" && activity.exitCode === 0) appendTerminalCheckIcon(status);
+ status.append(document.createTextNode(statusText));
+ footer.append(status);
+ }
+ shell.append(footer);
+ body.append(shell);
+ }
+
+ function renderActivityText(activity) {
+ const text = activityText(activity);
+ const visibleText = text || t(isRunningActivity(activity) ? "等待输出…" : activityStatusLabel(activity.status));
+ if (activity.kind === "tool" || activity.kind === "read") renderTerminalBody(activity);
+ else if (activity.kind === "subagent") renderSubagentBody(activity);
+ else renderMessageBody(activity.body, visibleText, activity.role, "activity", activity.messageKind);
+ activity.article.dataset.rawText = text;
+ }
+
+ function setActivityContent(activity, header, output, append = false) {
+ if (!activity) return;
+ if (header !== undefined && header !== null && String(header)) activity.headerText = String(header);
+ if (output !== undefined && output !== null) {
+ activity.outputText = append ? `${activity.outputText}${String(output)}` : String(output);
+ }
+ renderActivityText(activity);
+ }
+
+ function activityElapsed(activity) {
+ if (activity.durationMs !== null) return Math.max(0, activity.durationMs);
+ if (!activity.startedAt) return null;
+ // A completed legacy row without an end marker has an unknown duration;
+ // showing the current wall clock makes old work appear to keep running.
+ if (!isRunningActivity(activity) && !activity.finishedAt) return null;
+ const end = activity.finishedAt || Date.now();
+ return Math.max(0, end - activity.startedAt);
+ }
+
+ function refreshActivity(activity) {
+ if (!activity?.summary) return;
+ const elapsed = activityElapsed(activity);
+ // The official activity rows only show an item duration when the owner
+ // supplied one. A timestamp interval inferred while hydrating history is
+ // useful for the turn clock, but should not become a misleading per-row
+ // wall-clock label.
+ const duration = activity.durationExplicit === true ? elapsedDuration(elapsed) : "";
+ const label = t(activity.label || "执行步骤");
+ const setSummary = (value) => setActivitySummary(activity.summary, value, activity.kind);
+ // Recompute the summary in the active locale. Dynamic command/path/name
+ // values are appended as raw strings by historyActivitySummary().
+ if (activity.kind === "subagent") {
+ const name = activity.displayName || activity.label || t("子代理");
+ const statusText = activity.status === "inProgress" ? t("已开始工作")
+ : activity.status === "completed" ? t("已完成")
+ : activity.status === "failed" ? t("失败")
+ : activity.status === "interrupted" ? t("已中断") : "";
+ setSubagentSummary(activity.summary, name, statusText);
+ } else {
+ const localizedSummary = historyActivitySummary(activity, activity.kind, activity.status, duration);
+ if (localizedSummary) setSummary(localizedSummary);
+ else if (activity.status === "inProgress") setSummary(label);
+ else if (activity.status === "failed") setSummary(`${label} · ${t("失败")}`);
+ else if (activity.status === "interrupted") setSummary(`${label} · ${t("已中断")}`);
+ else setSummary(label);
+ }
+ activity.article.dataset.status = activity.status;
+ }
+
+ function finishActivity(activity, status = "completed", durationMs, finishedAt) {
+ if (!activity) return;
+ // `updateLiveActivity` may create a short-lived row before the host emits
+ // an item lifecycle event. It is useful while the turn is live, but the
+ // official transcript does not retain a blank "思考/编辑/读取" item once
+ // the turn ends. A concrete lifecycle row (or a row that received real
+ // output) has already cleared `statusOnly` and follows the normal path.
+ if (activity.statusOnly === true && activity.concrete !== true) {
+ retireActivity(activity);
+ return;
+ }
+ activity.status = normalizeActivityStatus(status, "completed");
+ activity.finishedAt = timestampMs(finishedAt) || Date.now();
+ const explicitDuration = finiteNumber(durationMs);
+ activity.durationExplicit = explicitDuration !== null;
+ const inferredDuration = activity.startedAt === null
+ ? null
+ : Math.max(0, activity.finishedAt - activity.startedAt);
+ activity.durationMs = explicitDuration === null
+ ? inferredDuration
+ : Math.max(0, explicitDuration);
+ activity.article.classList.remove("streaming");
+ renderActivityText(activity);
+ refreshActivity(activity);
+ if (activity.details && (activity.kind === "reasoning" || activity.kind === "plan" || activity.kind === "subagent")) {
+ setDetailsExpanded(activity.details, false);
+ }
+ if (state.activeAssistantActivityKey === activity.key) {
+ state.activeAssistantBody = null;
+ state.activeAssistantStream = null;
+ state.activeAssistantText = "";
+ state.activeAssistantActivityKey = null;
+ }
+ stopActivityTimerIfIdle();
+ }
+
+ function latestRunningActivity(kind, context = {}) {
+ const itemId = context.itemId === undefined || context.itemId === null ? "" : String(context.itemId);
+ const turnId = context.turnId || state.turnId || "";
+ const activities = [...state.activities.values()].reverse();
+ if (itemId) {
+ const exact = activities.find((activity) => activity.itemId === itemId && isRunningActivity(activity));
+ if (exact) return exact;
+ }
+ const acceptedKinds = kind === "reasoning" ? new Set(["reasoning", "plan"]) : new Set([kind]);
+ return activities.find((activity) => isRunningActivity(activity)
+ && acceptedKinds.has(activity.kind)
+ && (!turnId || !activity.turnId || activity.turnId === turnId)) || null;
+ }
+
+ function appendActivityChunk(activity, text) {
+ if (!activity || !text) return;
+ // A stream chunk is evidence of a real work item, even when the host
+ // omitted its item id. It should not be mistaken for a status-only row at
+ // turn completion.
+ activity.concrete = true;
+ activity.statusOnly = false;
+ activity.status = "inProgress";
+ setActivityContent(activity, undefined, text, true);
+ activity.article.classList.add("streaming");
+ if ((activity.kind === "reasoning" || activity.kind === "plan" || activity.kind === "commentary") && activity.details) {
+ // Reasoning is the one activity the official transcript expands while
+ // it is streaming; completed reasoning collapses again in finishActivity.
+ setDetailsExpanded(activity.details, true);
+ }
+ refreshActivity(activity);
+ ensureActivityTimer();
+ }
+
+ function handleItemLifecycle(phase, payload) {
+ const params = eventParams(payload);
+ const item = itemFromPayload(payload);
+ const kind = activityKindForItem(item);
+ if (!kind || kind === "user" || kind === "assistant") return false;
+ const itemId = item.id ?? params.itemId ?? payload?.itemId;
+ const duration = finiteNumber(item.durationMs, params.durationMs);
+ const finishedAt = timestampMs(item.completedAtMs, item.finishedAtMs, params.completedAtMs, params.emittedAtMs);
+ const startedAt = timestampMs(item.startedAtMs, item.startedAt, params.startedAtMs)
+ || (duration !== null && finishedAt ? finishedAt - duration : null);
+ const fallbackStatus = phase === "completed" ? "completed" : "inProgress";
+ const activity = ensureActivity(activityKey(payload, item, kind), {
+ kind,
+ label: activityLabelForItem(item, kind),
+ command: kind === "tool" || kind === "read" ? terminalCommandText(commandText(item)) : "",
+ filePath: kind === "read" ? readPathList(item).join("\n") : "",
+ cwd: (kind === "tool" || kind === "read") && typeof item.cwd === "string" ? item.cwd : "",
+ shellName: (kind === "tool" || kind === "read") && typeof item.shellName === "string" ? item.shellName : "Shell",
+ agentThreadId: firstString(item.agentThreadId, item.childThreadId, item.threadId),
+ displayName: firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath),
+ objective: firstString(item.objective, item.prompt, item.statusMessage, item.message),
+ activityKind: firstString(item.activityKind, item.kind),
+ displayStatus: firstString(item.displayStatus, item.status),
+ model: firstString(item.model, item.modelId),
+ action: firstString(item.action, item.tool),
+ prompt: item.prompt === null ? "" : firstString(item.prompt),
+ senderThreadId: firstString(item.senderThreadId),
+ receiverThreadIds: Array.isArray(item.receiverThreadIds)
+ ? item.receiverThreadIds.map(String)
+ : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : [],
+ agentsStates: isRecord(item.agentsStates) ? item.agentsStates : {},
+ canInteract: item.canInteract !== false,
+ exitCode: kind === "tool" || kind === "read" ? finiteNumber(item.exitCode, item.exit_code) : undefined,
+ itemId,
+ threadId: eventThreadId(payload),
+ turnId: eventTurnId(payload),
+ startedAt,
+ status: normalizeActivityStatus(item.status, fallbackStatus),
+ concrete: true,
+ anonymous: itemId === undefined || itemId === null,
+ });
+ if (!activity) return true;
+ if (kind === "tool" || kind === "read") {
+ const command = terminalCommandText(commandText(item));
+ if (command) activity.command = command;
+ if (kind === "read") activity.filePath = readPathList(item).join("\n") || activity.filePath;
+ if (typeof item.cwd === "string") activity.cwd = item.cwd;
+ if (typeof item.shellName === "string" && item.shellName) activity.shellName = item.shellName;
+ const exitCode = finiteNumber(item.exitCode, item.exit_code);
+ if (exitCode !== null) activity.exitCode = exitCode;
+ }
+ if (kind === "subagent") {
+ activity.agentThreadId = firstString(item.agentThreadId, item.childThreadId, item.threadId, activity.agentThreadId);
+ activity.displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, activity.displayName);
+ activity.objective = firstString(item.objective, item.prompt, item.statusMessage, item.message, activity.objective);
+ activity.activityKind = firstString(item.activityKind, item.kind, activity.activityKind);
+ activity.displayStatus = firstString(item.displayStatus, item.status, activity.displayStatus);
+ activity.model = firstString(item.model, item.modelId, activity.model);
+ activity.action = firstString(item.action, item.tool, activity.action);
+ activity.prompt = item.prompt === null ? "" : firstString(item.prompt, activity.prompt);
+ activity.senderThreadId = firstString(item.senderThreadId, activity.senderThreadId);
+ if (Array.isArray(item.receiverThreadIds)) activity.receiverThreadIds = item.receiverThreadIds.map(String);
+ else if (Array.isArray(item.receiverThreads)) activity.receiverThreadIds = item.receiverThreads.map(String);
+ if (isRecord(item.agentsStates)) activity.agentsStates = item.agentsStates;
+ if (item.canInteract !== undefined) activity.canInteract = item.canInteract !== false;
+ if (activity.agentThreadId) activity.article.dataset.agentThreadId = activity.agentThreadId;
+ else delete activity.article.dataset.agentThreadId;
+ }
+ const header = activityHeader(item, kind);
+ const output = activityOutput(item, kind);
+ if (header || output) setActivityContent(activity, header, output);
+ if (phase === "completed") {
+ const status = normalizeActivityStatus(item.status, item.error ? "failed" : "completed");
+ finishActivity(activity, status, duration, finishedAt);
+ } else {
+ activity.status = normalizeActivityStatus(item.status, "inProgress");
+ refreshActivity(activity);
+ }
+ return true;
+ }
+
+ function handlePlanUpdate(payload) {
+ const params = eventParams(payload);
+ const steps = Array.isArray(params.plan) ? params.plan : Array.isArray(params.steps) ? params.steps : null;
+ const text = planText(steps || params.text || params);
+ if (!text) return false;
+ const threadId = eventThreadId(payload) || state.threadId || "thread";
+ const turnId = eventTurnId(payload) || state.turnId || "turn";
+ const activity = ensureActivity(`plan:${threadId}:${turnId}`, {
+ kind: "plan",
+ label: "计划",
+ threadId,
+ turnId,
+ status: "inProgress",
+ concrete: true,
+ });
+ setActivityContent(activity, "", text);
+ const statuses = (steps || []).map((step) => normalizeActivityStatus(step?.status, "pending"));
+ if (statuses.length && statuses.every((status) => status === "completed")) finishActivity(activity, "completed");
+ else refreshActivity(activity);
+ return true;
+ }
+
+ function handleDiffUpdate(payload) {
+ const params = eventParams(payload);
+ const diff = fileChangesText(params.changes || params.diff || params.patch || params.delta || params.text || params.output);
+ if (!diff) return false;
+ const threadId = eventThreadId(payload) || state.threadId || "thread";
+ const turnId = eventTurnId(payload) || state.turnId || "turn";
+ const activity = ensureActivity(`diff:${threadId}:${turnId}`, {
+ kind: "edit",
+ label: "文件变更",
+ threadId,
+ turnId,
+ status: "inProgress",
+ concrete: true,
+ });
+ setActivityContent(activity, "", diff);
+ refreshActivity(activity);
+ return true;
+ }
+
+ function finishActivitiesForTurn(turnId, status) {
+ for (const activity of state.activities.values()) {
+ if (!isRunningActivity(activity)) continue;
+ if (turnId && activity.turnId && activity.turnId !== turnId) continue;
+ finishActivity(activity, status);
+ }
+ }
+
+ function turnStatusLabel(status) {
+ if (status === "waiting") return t("等待授权");
+ if (status === "generating") return t("正在生成");
+ if (status === "interrupted") return t("已中断");
+ if (status === "failed") return t("失败");
+ if (status === "completed") return t("已完成");
+ return t("正在工作");
+ }
+
+ function statusActivityLabel(activity, flags = []) {
+ const normalized = String(activity || "").replace(/[\s-]+/g, "_").toLowerCase();
+ if (normalized === "waiting_approval" || normalized === "waiting_for_approval" || flags.some((flag) => /approval|permission/.test(String(flag)))) return t("等待授权");
+ if (normalized === "waiting_input" || normalized === "waiting_for_user_input" || flags.some((flag) => /user.?input/.test(String(flag)))) return t("正在等待你的回答");
+ if (normalized === "thinking" || normalized === "reasoning") return t("正在思考");
+ if (normalized === "editing" || normalized === "edit") return t("正在编辑文件");
+ if (normalized === "reading" || normalized === "reading_file" || normalized === "file_read") return t("正在读取文件");
+ if (normalized === "running" || normalized === "running_command" || normalized === "tool") return t("正在运行命令");
+ if (normalized === "searching" || normalized === "searching_web") return t("正在搜索网页");
+ if (normalized === "responding" || normalized === "generating") return t("正在生成");
+ if (normalized === "failed") return t("执行失败");
+ if (normalized === "interrupted") return t("已中断");
+ if (normalized === "completed") return t("已完成");
+ return normalized && normalized !== "idle" ? t("处理中") : "";
+ }
+
+ function updateLiveActivity(activity, startedAt, durationMs, flags = [], turnId = "") {
+ const element = $("liveActivity");
+ if (!element) return;
+ const normalized = String(activity || "idle").replace(/[\s-]+/g, "_").toLowerCase();
+ const terminal = ["idle", "ready", "completed", "failed", "interrupted", "cancelled", "canceled"].includes(normalized);
+ state.currentActivity = normalized;
+ state.currentActivityStartedAt = timestampMs(startedAt) || state.currentActivityStartedAt;
+ state.currentActivityDurationMs = finiteNumber(durationMs);
+ state.currentActivityTurnId = turnId || state.currentActivityTurnId || "";
+ if (terminal) {
+ const live = state.liveActivityKey ? state.activities.get(state.liveActivityKey) : null;
+ // Concrete lifecycle rows are finalized by item/turn completion events.
+ // A status transition may only close an anonymous streaming fallback;
+ // closing a concrete row here can race the owner snapshot and erase its
+ // command/edit label before the real completion payload arrives.
+ if (live?.anonymous && isRunningActivity(live)) {
+ finishActivity(live, normalized === "failed" ? "failed" : normalized === "interrupted" ? "interrupted" : "completed", durationMs);
+ }
+ state.liveActivityKey = null;
+ element.hidden = true;
+ element.dataset.activity = normalized;
+ element.dataset.active = "false";
+ return;
+ }
+ const label = statusActivityLabel(normalized, flags);
+ if (!label) {
+ element.hidden = true;
+ return;
+ }
+ // The live status node remains available to assistive technology, while
+ // the visible transcript is the source of truth for work-in-progress rows.
+ element.hidden = false;
+ element.dataset.activity = normalized;
+ element.dataset.active = "true";
+ const labelElement = element.querySelector(".activity-label");
+ const elapsedElement = element.querySelector(".activity-elapsed");
+ const activeTranscript = latestRunningActivity(
+ normalized === "thinking" || normalized === "reasoning" ? "reasoning"
+ : normalized === "editing" || normalized === "edit" ? "edit"
+ : normalized === "reading" || normalized === "reading_file" || normalized === "file_read" ? "read"
+ : normalized === "searching" || normalized === "searching_web" ? "tool"
+ : normalized === "running" || normalized === "running_command" || normalized === "tool" ? "tool" : "",
+ { turnId: turnId || state.turnId || "" },
+ );
+ let visibleLabel = label;
+ if (activeTranscript) {
+ if (activeTranscript.kind === "tool" && activeTranscript.command) {
+ visibleLabel = uiWithRaw("正在运行 ", "Running ", terminalCommandText(activeTranscript.command));
+ } else if (activeTranscript.kind === "edit") visibleLabel = t("正在编辑文件");
+ else if (activeTranscript.kind === "read") visibleLabel = t("正在读取文件");
+ else if (activeTranscript.kind === "reasoning") visibleLabel = t("正在思考");
+ else if (activeTranscript.label) visibleLabel = activeTranscript.label;
+ }
+ if (labelElement) labelElement.textContent = visibleLabel;
+ const elapsed = state.currentActivityStartedAt === null
+ ? state.currentActivityDurationMs
+ : Math.max(0, Date.now() - state.currentActivityStartedAt);
+ if (elapsedElement) elapsedElement.textContent = elapsedDuration(elapsed);
+
+ // Status snapshots are projections of the current turn, not work items.
+ // Bind to an existing concrete lifecycle row when possible. For the few
+ // builds that publish a status before its item, the fallback below creates
+ // a transient, explicitly `statusOnly` row that is removed at completion;
+ // waiting approvals and ordinary status ticks never become history items.
+ const effectiveTurnId = turnId || state.turnId || "";
+ const transcriptKind = normalized === "thinking" || normalized === "reasoning"
+ ? "reasoning"
+ : normalized === "editing" || normalized === "edit"
+ ? "edit"
+ : normalized === "reading" || normalized === "reading_file" || normalized === "file_read"
+ ? "read"
+ : normalized === "running" || normalized === "running_command" || normalized === "tool"
+ ? "tool"
+ : normalized === "searching" || normalized === "searching_web"
+ ? "tool"
+ : "";
+ let transcript = transcriptKind
+ ? latestRunningActivity(transcriptKind, { turnId: effectiveTurnId })
+ : null;
+ if (!transcript && effectiveTurnId && (transcriptKind === "reasoning" || transcriptKind === "edit" || transcriptKind === "read")) {
+ // A few official builds publish the turn activity before the first
+ // reasoning/diff item. Materialize one stable anonymous row so the
+ // reader sees "正在思考"/"正在编辑文件" immediately; a later concrete
+ // lifecycle item is merged into the same visual stream by kind/turn.
+ const key = `status:${state.threadId || "thread"}:${effectiveTurnId}:${transcriptKind}`;
+ transcript = ensureActivity(key, {
+ kind: transcriptKind,
+ label: transcriptKind === "reasoning" ? "思考" : transcriptKind === "edit" ? "编辑文件" : "读取文件",
+ threadId: state.threadId,
+ turnId: effectiveTurnId,
+ startedAt: state.currentActivityStartedAt || state.turnStartedAt,
+ status: "inProgress",
+ anonymous: true,
+ statusOnly: true,
+ open: false,
+ });
+ }
+ if (transcript) {
+ state.liveActivityKey = transcript.key;
+ // Keep the concrete item label (command, tool name, or edit summary)
+ // supplied by its lifecycle event. The global status must not overwrite
+ // it with a generic "正在运行命令"/"正在思考" label.
+ transcript.status = "inProgress";
+ if (state.currentActivityStartedAt && !transcript.startedAt) transcript.startedAt = state.currentActivityStartedAt;
+ refreshActivity(transcript);
+ if (effectiveTurnId) ensureLiveTurnDivider(effectiveTurnId);
+ } else {
+ state.liveActivityKey = null;
+ }
+ }
+
+ function refreshTurnClock() {
+ if (state.turnStartedAt === null) return;
+ const elapsed = Math.max(0, Date.now() - state.turnStartedAt);
+ const workElapsed = state.turnWorkStartedAt === null
+ ? elapsed
+ : Math.max(0, Date.now() - state.turnWorkStartedAt);
+ const activity = state.currentActivity && state.currentActivity !== "idle"
+ ? statusActivityLabel(state.currentActivity)
+ : turnStatusLabel(state.turnStatus);
+ const visibleElapsed = elapsedDuration(workElapsed);
+ setConversationStatus(visibleElapsed ? `${activity} · ${visibleElapsed}` : activity, state.turnStatus === "waiting" ? "warning" : "active");
+ const divider = state.turnDividers.get(state.turnId)
+ || [...$("output").querySelectorAll(".turn-divider")]
+ .find((entry) => entry.dataset.turnId === state.turnId);
+ if (divider?.dataset.status === "inProgress") {
+ const label = divider.querySelector(".turn-divider-label");
+ if (label) label.textContent = turnDividerLabel("inProgress", workElapsed);
+ }
+ updateLiveActivity(state.currentActivity || "running", state.currentActivityStartedAt || state.turnStartedAt, null, [], state.currentActivityTurnId || state.turnId);
+ }
+
+ function startTurnClock(turnId, startedAt, elapsedMs) {
+ const nextTurnId = turnId || state.turnId || "";
+ const explicitStartedAt = timestampMs(startedAt);
+ const explicitElapsed = finiteNumber(elapsedMs);
+ if (nextTurnId && state.turnId && nextTurnId !== state.turnId) {
+ state.turnStartedAt = null;
+ state.turnWorkStartedAt = null;
+ state.finalAssistantStartedAt = null;
+ state.workedDurationMs = null;
+ state.currentActivityStartedAt = null;
+ state.currentActivityDurationMs = null;
+ state.currentActivity = "running";
+ }
+ if (nextTurnId) state.turnId = nextTurnId;
+ if (state.turnStartedAt === null) {
+ state.turnStartedAt = explicitStartedAt
+ || (explicitElapsed !== null ? Date.now() - Math.max(0, explicitElapsed) : Date.now());
+ }
+ state.turnStatus = "active";
+ state.lastTurnDurationMs = null;
+ state.lastWorkedDurationMs = null;
+ if (state.turnWorkStartedAt === null) state.turnWorkStartedAt = state.turnStartedAt;
+ const output = $("output");
+ if (output && [...output.querySelectorAll(".message.activity")]
+ .some((article) => article.dataset.turnId === nextTurnId)) ensureLiveTurnDivider(nextTurnId);
+ refreshTurnClock();
+ ensureActivityTimer();
+ }
+
+ function stopTurnClock(status = "completed", durationMs, finishedAt, workedDurationMs) {
+ // Some completion envelopes do not carry an item lifecycle event and do
+ // not leave `liveActivityKey` pointing at the transient status row. Sweep
+ // those rows here as a final safety net before clearing the turn clock.
+ retireStatusOnlyActivities(state.turnId);
+ const end = timestampMs(finishedAt) || Date.now();
+ const explicitDuration = finiteNumber(durationMs);
+ const elapsed = explicitDuration !== null
+ ? Math.max(0, explicitDuration)
+ : state.turnStartedAt === null ? null : Math.max(0, end - state.turnStartedAt);
+ // A hydrated terminal snapshot can arrive immediately after metadata. In
+ // that case the metadata duration is authoritative even though the
+ // terminal status envelope does not repeat it.
+ const authoritativeWorkedDuration = finiteNumber(
+ workedDurationMs,
+ state.workedDurationMs,
+ state.lastWorkedDurationMs,
+ );
+ const worked = workedDurationFor({
+ workedDurationMs: authoritativeWorkedDuration,
+ firstTurnWorkItemStartedAtMs: state.turnWorkStartedAt,
+ finalAssistantStartedAtMs: state.finalAssistantStartedAt,
+ completedAtMs: end,
+ }, state.turnWorkStartedAt === null ? elapsed : Math.max(0, end - state.turnWorkStartedAt));
+ state.turnStartedAt = null;
+ state.turnWorkStartedAt = null;
+ state.turnStatus = status;
+ state.lastTurnDurationMs = elapsed;
+ state.lastWorkedDurationMs = worked;
+ state.workedDurationMs = worked;
+ const duration = elapsedDuration(worked ?? elapsed);
+ setConversationStatus(`${turnStatusLabel(status)}${duration ? ` · ${duration}` : ""}`, status === "completed" ? "ready" : "warning");
+ state.currentActivity = status;
+ state.currentActivityStartedAt = null;
+ state.currentActivityDurationMs = worked ?? elapsed;
+ updateLiveActivity(status, null, worked ?? elapsed, [], state.turnId);
+ stopActivityTimerIfIdle();
+ }
+
+ function turnStatusFromValue(value, fallback = "active") {
+ const normalized = normalizeActivityStatus(value, fallback);
+ if (normalized === "inProgress") return "active";
+ if (normalized === "declined" || normalized === "interrupted") return "interrupted";
+ return normalized;
+ }
+
+ function applyStatusSnapshot(payload, options = {}) {
+ const status = isRecord(payload?.status) ? payload.status : {};
+ const metadata = isRecord(payload?.metadata) ? payload.metadata : {};
+ const rawTurnStatus = payload?.turnStatus ?? status.turnStatus ?? metadata.turnStatus ?? payload?.state;
+ const activity = String(payload?.activity ?? status.activity ?? metadata.activity ?? "").toLowerCase();
+ const flags = [
+ ...(Array.isArray(payload?.activeFlags) ? payload.activeFlags : []),
+ ...(Array.isArray(status.activeFlags) ? status.activeFlags : []),
+ ...(Array.isArray(metadata.activeFlags) ? metadata.activeFlags : []),
+ ].map((flag) => String(flag).toLowerCase());
+ const turnId = payload?.turnId || state.turnId || "";
+ const projectedWorkedDuration = workedDurationFor(payload,
+ workedDurationFor(status, workedDurationFor(metadata, null)));
+ const projectedWorkStart = timestampMs(
+ payload?.firstTurnWorkItemStartedAtMs,
+ payload?.workStartedAtMs,
+ status.firstTurnWorkItemStartedAtMs,
+ status.workStartedAtMs,
+ metadata.firstTurnWorkItemStartedAtMs,
+ metadata.workStartedAtMs,
+ );
+ const projectedFinalAssistantStart = timestampMs(
+ payload?.finalAssistantStartedAtMs,
+ status.finalAssistantStartedAtMs,
+ metadata.finalAssistantStartedAtMs,
+ );
+ if (projectedWorkStart !== null) state.turnWorkStartedAt = projectedWorkStart;
+ if (projectedFinalAssistantStart !== null) state.finalAssistantStartedAt = projectedFinalAssistantStart;
+ if (projectedWorkedDuration !== null) state.workedDurationMs = projectedWorkedDuration;
+ const rawNormalized = String(rawTurnStatus || "").replace(/[\s-]+/g, "_").toLowerCase();
+ const normalized = turnStatusFromValue(rawTurnStatus || (turnId ? "active" : "idle"), turnId ? "active" : "idle");
+ const terminal = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"].includes(rawNormalized)
+ || ["completed", "failed", "interrupted"].includes(normalized);
+ const effectiveActivity = activity || (flags.some((flag) => /approval|permission/.test(flag)) ? "waiting_approval" : turnId ? "running" : normalized);
+ const explicitlyActive = !terminal && (Boolean(turnId)
+ || ["active", "running", "working", "inprogress", "thinking", "reasoning", "editing", "edit", "reading", "readingfile", "fileread", "searching", "responding", "generating"].includes(activity.replace(/[\s_-]+/g, ""))
+ || normalized === "active");
+ if (explicitlyActive) {
+ startTurnClock(
+ turnId,
+ payload?.startedAtMs ?? status.startedAtMs ?? metadata.startedAtMs,
+ payload?.elapsedMs ?? status.elapsedMs ?? metadata.elapsedMs,
+ );
+ state.turnStatus = flags.some((flag) => /approval|permission|input|waiting/.test(flag)) ? "waiting" : "active";
+ state.currentActivity = effectiveActivity;
+ updateLiveActivity(effectiveActivity, payload?.startedAtMs ?? status.startedAtMs ?? metadata.startedAtMs ?? state.turnStartedAt, payload?.durationMs ?? status.durationMs ?? metadata.durationMs, flags, turnId);
+ refreshTurnClock();
+ return;
+ }
+ if (options.allowTerminal === false) return;
+ const duration = payload?.durationMs ?? status.durationMs ?? metadata.durationMs;
+ if (["completed", "interrupted", "failed"].includes(normalized) || terminal) {
+ stopTurnClock(normalized, duration, payload?.completedAtMs ?? status.completedAtMs ?? metadata.completedAtMs, projectedWorkedDuration);
+ }
+ else if (state.turnStartedAt === null && options.showIdle !== false) setConversationStatus("ready");
+ }
+
+ function snapshotStatusProjection(snapshot = {}, appState = {}) {
+ const snapshotRecord = isRecord(snapshot) ? snapshot : {};
+ const stateRecord = isRecord(appState) ? appState : {};
+ const statusRecord = [
+ snapshotRecord.executionStatus,
+ snapshotRecord.status,
+ stateRecord.executionStatus,
+ stateRecord.status,
+ ].find(isRecord) || {};
+ const rawTurnStatus = firstDefined(
+ snapshotRecord.turnStatus,
+ statusRecord.turnStatus,
+ statusRecord.status,
+ stateRecord.turnStatus,
+ typeof stateRecord.status === "string" ? stateRecord.status : undefined,
+ );
+ const rawActivity = firstString(
+ snapshotRecord.activity,
+ statusRecord.activity,
+ stateRecord.activity,
+ ).toLowerCase();
+ const rawNormalized = String(rawTurnStatus || "").replace(/[\s-]+/g, "_").toLowerCase();
+ const fallbackStatus = rawActivity === "completed" || rawActivity === "complete" || rawActivity === "done"
+ ? "completed"
+ : rawActivity === "failed" || rawActivity === "error"
+ ? "failed"
+ : rawActivity === "interrupted" || rawActivity === "cancelled" || rawActivity === "canceled"
+ ? "interrupted"
+ : snapshotRecord.turnId || stateRecord.activeTurnId ? "active" : "idle";
+ const normalized = turnStatusFromValue(rawTurnStatus ?? fallbackStatus, fallbackStatus);
+ const terminal = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"].includes(rawNormalized)
+ || ["completed", "failed", "interrupted"].includes(normalized)
+ || ["completed", "failed", "interrupted"].includes(rawActivity);
+ const activeActivity = ["active", "running", "working", "inprogress", "thinking", "reasoning", "editing", "edit", "reading", "reading_file", "file_read", "searching", "searching_web", "responding", "generating"].includes(rawActivity.replace(/[\s-]+/g, "_"));
+ const explicitTurnId = snapshotRecord.turnId !== undefined
+ ? snapshotRecord.turnId
+ : stateRecord.activeTurnId;
+ return {
+ normalized,
+ terminal,
+ hasActiveTurn: Boolean(explicitTurnId) || activeActivity || normalized === "active" || normalized === "waiting",
+ durationMs: finiteNumber(
+ snapshotRecord.durationMs,
+ snapshotRecord.elapsedMs,
+ statusRecord.durationMs,
+ statusRecord.elapsedMs,
+ stateRecord.durationMs,
+ stateRecord.elapsedMs,
+ ),
+ workedDurationMs: workedDurationFor(snapshotRecord,
+ workedDurationFor(statusRecord, workedDurationFor(stateRecord, null))),
+ firstTurnWorkItemStartedAtMs: timestampMs(
+ snapshotRecord.firstTurnWorkItemStartedAtMs,
+ snapshotRecord.workStartedAtMs,
+ statusRecord.firstTurnWorkItemStartedAtMs,
+ statusRecord.workStartedAtMs,
+ stateRecord.firstTurnWorkItemStartedAtMs,
+ stateRecord.workStartedAtMs,
+ ),
+ finalAssistantStartedAtMs: timestampMs(
+ snapshotRecord.finalAssistantStartedAtMs,
+ statusRecord.finalAssistantStartedAtMs,
+ stateRecord.finalAssistantStartedAtMs,
+ ),
+ completedAtMs: timestampMs(snapshotRecord.completedAtMs, statusRecord.completedAtMs, stateRecord.completedAtMs),
+ };
+ }
+
+ // A late authoritative snapshot can arrive after replayed lifecycle events.
+ // Once it says there is no active turn, clear only the transient projection;
+ // hydrated history remains intact and a locally queued user message is kept.
+ function reconcileSnapshotTerminalState(snapshot = {}, appState = {}) {
+ const projection = snapshotStatusProjection(snapshot, appState);
+ if (!projection.terminal || projection.hasActiveTurn || state.pendingUserText) return;
+ const hadTransientTurn = Boolean(
+ state.turnId
+ || state.turnStartedAt !== null
+ || state.currentActivity === "active"
+ || state.currentActivity === "running"
+ || state.currentActivity === "working"
+ || state.currentActivity === "thinking"
+ || state.currentActivity === "editing"
+ || state.currentActivity === "generating"
+ || [...state.activities.values()].some(isRunningActivity),
+ );
+ if (!hadTransientTurn) return;
+ const finishedTurnId = state.turnId;
+ finishAssistantStream();
+ finishActivitiesForTurn(finishedTurnId, projection.normalized);
+ stopTurnClock(projection.normalized, projection.durationMs, projection.completedAtMs, projection.workedDurationMs);
+ state.turnId = "";
+ state.currentActivityTurnId = "";
+ state.currentActivityStartedAt = null;
+ state.currentActivityDurationMs = projection.durationMs;
+ updateIds();
+ }
+
+ function appendFileChangeChunk(payload, text) {
+ if (!text) return false;
+ const params = eventParams(payload);
+ const threadId = eventThreadId(payload) || state.threadId || "thread";
+ const turnId = eventTurnId(payload) || state.turnId || "turn";
+ const itemId = params.itemId ?? payload?.itemId;
+ let activity = latestRunningActivity("edit", { itemId, turnId });
+ if (!activity) {
+ const key = itemId === undefined
+ ? `diff:${threadId}:${turnId}`
+ : `${threadId}:${turnId}:edit:${typeof itemId}:${String(itemId)}`;
+ activity = ensureActivity(key, {
+ kind: "edit",
+ label: "编辑文件",
+ itemId,
+ threadId,
+ turnId,
+ status: "inProgress",
+ concrete: true,
+ });
+ }
+ appendActivityChunk(activity, text);
+ return true;
+ }
+
+ function refreshElapsedDisplays() {
+ refreshTurnClock();
+ for (const activity of state.activities.values()) if (isRunningActivity(activity)) refreshActivity(activity);
+ refreshSubagentElapsed();
+ if (state.currentActivity && state.currentActivity !== "idle") {
+ updateLiveActivity(
+ state.currentActivity,
+ state.currentActivityStartedAt || state.turnStartedAt,
+ state.currentActivityDurationMs,
+ [],
+ state.currentActivityTurnId || state.turnId,
+ );
+ }
+ stopActivityTimerIfIdle();
+ }
+
+ function ensureActivityTimer() {
+ if (state.activityTimer !== null) return;
+ state.activityTimer = setInterval(refreshElapsedDisplays, 250);
+ }
+
+ function stopActivityTimerIfIdle() {
+ if (state.turnStartedAt !== null || [...state.activities.values()].some(isRunningActivity)) return;
+ if (state.activityTimer !== null) clearInterval(state.activityTimer);
+ state.activityTimer = null;
+ }
+
+ function renderEmptyOutput() {
+ const output = $("output");
+ output.replaceChildren();
+ output.dataset.outputTail = "";
+ state.activeAssistantBody = null;
+ state.activeAssistantStream = null;
+ state.activeAssistantText = "";
+ state.activeAssistantActivityKey = null;
+ state.activities.clear();
+ state.turnDividers.clear();
+ state.liveActivityKey = null;
+ state.pendingUserArticle = null;
+ state.lastRenderedDateKey = "";
+ state.lastRenderedTimestamp = null;
+ state.lastRenderedRole = "";
+ state.hasRenderedUser = false;
+ state.lastDateSeparatorTimestamp = null;
+ state.outputDistanceFromBottom = 0;
+ state.structuredMessages = [];
+ stopActivityTimerIfIdle();
+ }
+
+ function appendOutput(text, tone) {
+ if (!text) return;
+ const visibleText = tone === "error" || tone === "meta" ? t(text) : text;
+ if (tone === "meta") {
+ setConversationStatus(String(visibleText));
+ return;
+ }
+ finishAssistantStream();
+ const role = tone === "error" ? "error" : tone === "meta" ? "system" : "assistant";
+ appendMessage(visibleText, role, tone || "text", tone === "meta" ? t("状态") : "");
+ }
+
+ function setConversationStatus(text, tone = "ready") {
+ const value = t(text || "");
+ const status = $("appState");
+ if (status) {
+ status.textContent = value;
+ status.dataset.tone = tone;
+ }
+ const hint = $("outputHint");
+ if (hint && value) hint.textContent = value;
+ }
+
+ function sessionCommandMethod(value) {
+ return String(value || "").trim().replace(/\./g, "/").toLowerCase();
+ }
+
+ function sessionErrorMessage(value, fallback = "会话操作失败") {
+ const source = isRecord(value) ? value : {};
+ const error = isRecord(source.error) ? source.error : {};
+ const code = firstString(source.code, error.code).toLowerCase();
+ const message = value instanceof Error
+ ? value.message
+ : firstString(source.message, error.message, typeof value === "string" ? value : "");
+ const normalized = `${code} ${message}`.toLowerCase();
+ if (/app_not_ready|app-server is not ready|waiting_for_host/.test(normalized)) return "等待 VS Code 主机连接";
+ if (/host_unavailable|host_disconnected|vscode host is disconnected/.test(normalized)) return "VS Code 主机未连接";
+ if (/mode_switch_pending/.test(normalized)) return "正在切换控制模式";
+ if (/mode_busy|cannot switch control mode/.test(normalized)) return "当前任务或请求完成后才能切换控制模式";
+ if (/session_busy|turn_active|running turn|pending request/.test(normalized)) return "当前任务结束或请求处理后才能切换";
+ if (/timed out waiting for a snapshot|snapshot from vscode|找不到会话.*owner|no live vscode owner/.test(normalized)) {
+ return "目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它";
+ }
+ if (/method_not_allowed/.test(normalized)) return "当前 relay 版本不支持此会话操作,请重启 relay";
+ return message || fallback;
+ }
+
+ function setSessionSwitchingVisual(switching) {
+ const active = Boolean(switching);
+ const panel = document.querySelector(".chat-panel");
+ if (panel) panel.dataset.sessionSwitching = String(active);
+ const output = $("output");
+ if (output) output.setAttribute("aria-busy", String(active));
+ updateIds();
+ renderRequests();
+ }
+
+ function sessionSwitchTargetTitle(threadId) {
+ const id = String(threadId || "");
+ if (!id || !Array.isArray(state.sessions)) return "";
+ const entry = state.sessions.find((candidate) => sessionEntryId(candidate) === id);
+ return entry ? sessionEntryTitle(entry) : "";
+ }
+
+ function beginSessionSwitchContext(threadId, title = "") {
+ const targetThreadId = String(threadId || "");
+ if (state.sessionSwitchContext) {
+ if (!targetThreadId || state.sessionSwitchContext.targetThreadId === targetThreadId) {
+ return state.sessionSwitchContext;
+ }
+ // A newer VS Code navigation can supersede an in-flight target. Retain
+ // the original fallback, but reset both completion gates for the new
+ // target so an acknowledgement/snapshot from the older route cannot
+ // unlock the composer.
+ state.sessionSwitchContext.targetThreadId = targetThreadId;
+ state.sessionSwitchContext.targetTitle = String(title || sessionSwitchTargetTitle(targetThreadId) || "");
+ state.sessionSwitchContext.targetSnapshotReady = false;
+ state.sessionSwitchContext.selectedAckReady = false;
+ return state.sessionSwitchContext;
+ }
+ const titleNode = $("threadTitle");
+ const previousThreadId = String(state.threadId || state.syncedThreadId || "");
+ state.sessionSwitchContext = {
+ previousThreadId,
+ previousTitle: titleNode?.textContent || "Codex",
+ targetThreadId,
+ targetTitle: String(title || sessionSwitchTargetTitle(targetThreadId) || ""),
+ targetSnapshotReady: false,
+ selectedAckReady: false,
+ };
+ return state.sessionSwitchContext;
+ }
+
+ function finishSessionSwitchContext() {
+ state.sessionSwitchContext = null;
+ setSessionSwitchingVisual(false);
+ }
+
+ function restoreSessionSwitchContext() {
+ const context = state.sessionSwitchContext;
+ if (!context) {
+ state.sessionSwitching = false;
+ state.sessionSelectCommandId = "";
+ setSessionSwitchingVisual(false);
+ return;
+ }
+ const titleNode = $("threadTitle");
+ if (titleNode && context.previousTitle) titleNode.textContent = context.previousTitle;
+ if (context.previousThreadId) {
+ state.threadId = context.previousThreadId;
+ state.sessionSelectedThreadId = context.previousThreadId;
+ } else {
+ state.sessionSelectedThreadId = "";
+ }
+ state.sessionSwitching = false;
+ state.sessionSelectCommandId = "";
+ finishSessionSwitchContext();
+ }
+
+ function failSessionSwitch(value, fallback = "会话切换失败") {
+ // A target projection may arrive before the adapter's final owner check.
+ // A later failure must still restore the old routing context; treating the
+ // early snapshot as success strands the browser on an unconfirmed target.
+ restoreSessionSwitchContext();
+ return sessionErrorMessage(value, fallback);
+ }
+
+ function finishSessionSwitchIfReady() {
+ const context = state.sessionSwitchContext;
+ if (!context || !context.targetSnapshotReady || !context.selectedAckReady) return false;
+ const targetThreadId = String(context.targetThreadId || "");
+ if (!targetThreadId || state.syncedThreadId !== targetThreadId) return false;
+ const titleNode = $("threadTitle");
+ if (context.targetTitle && titleNode) titleNode.textContent = context.targetTitle;
+ state.sessionSelectedThreadId = "";
+ state.sessionSwitching = false;
+ state.sessionSelectCommandId = "";
+ finishSessionSwitchContext();
+ syncSessionActive(targetThreadId);
+ return true;
+ }
+
+ function sessionEntryId(entry) {
+ if (!isRecord(entry)) return "";
+ const thread = isRecord(entry.thread) ? entry.thread : {};
+ return firstString(entry.threadId, entry.conversationId, entry.conversation_id, entry.id,
+ thread.threadId, thread.conversationId, thread.conversation_id, thread.id);
+ }
+
+ function sessionEntryTitle(entry) {
+ if (!isRecord(entry)) return "";
+ const thread = isRecord(entry.thread) ? entry.thread : {};
+ return firstString(entry.title, entry.name, entry.preview, entry.firstUserMessage, entry.first_user_message,
+ entry.threadTitle, entry.thread_name, thread.title, thread.name, thread.preview, thread.thread_name);
+ }
+
+ function sessionEntryCwd(entry) {
+ if (!isRecord(entry)) return "";
+ const thread = isRecord(entry.thread) ? entry.thread : {};
+ return firstString(entry.cwd, entry.workspace, entry.workspacePath, entry.workspace_path,
+ thread.cwd, thread.workspace, thread.workspacePath, thread.workspace_path);
+ }
+
+ function sessionEntryUpdatedAt(entry) {
+ if (!isRecord(entry)) return null;
+ const thread = isRecord(entry.thread) ? entry.thread : {};
+ // App-server history is ordered by recency_at. Older relay versions only
+ // expose updatedAt, so keep those fields as a compatibility fallback.
+ return timestampMs(
+ entry.recencyAtMs, entry.recencyAt, entry.recency_at_ms, entry.recency_at,
+ thread.recencyAtMs, thread.recencyAt, thread.recency_at_ms, thread.recency_at,
+ entry.updatedAtMs, entry.updatedAt, entry.lastUpdatedAtMs, entry.lastUpdatedAt,
+ entry.updated_at_ms, entry.updated_at, entry.last_updated_at,
+ entry.mtime, entry.modifiedAt, thread.updatedAtMs, thread.updatedAt,
+ thread.updated_at_ms, thread.updated_at,
+ );
+ }
+
+ function sessionEntryStatus(entry) {
+ if (!isRecord(entry)) return { kind: "idle", label: "", active: false, attention: false, unread: false };
+ const thread = isRecord(entry.thread) ? entry.thread : {};
+ const nestedStatus = isRecord(entry.status) ? entry.status : {};
+ const nestedExecutionStatus = isRecord(entry.executionStatus) ? entry.executionStatus : {};
+ const nestedThreadStatus = isRecord(thread.status) ? thread.status : {};
+ let rawStatus = firstString(
+ entry.activity, entry.activityStatus, entry.status, entry.executionStatus, entry.turnStatus,
+ entry.threadRuntimeStatus, entry.thread_runtime_status, entry.runtimeStatus, entry.lastTurnStatus,
+ entry.last_turn_status, entry.phase, entry.state,
+ nestedStatus.activity, nestedStatus.type, nestedStatus.status, nestedStatus.kind,
+ nestedExecutionStatus.activity, nestedExecutionStatus.type, nestedExecutionStatus.status, nestedExecutionStatus.kind,
+ nestedThreadStatus.activity, nestedThreadStatus.type, nestedThreadStatus.status, nestedThreadStatus.kind,
+ thread.threadRuntimeStatus, thread.thread_runtime_status, thread.turnStatus, thread.lastTurnStatus, thread.state,
+ ).replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_");
+ if ((!rawStatus || rawStatus === "idle") && entry.active) {
+ rawStatus = firstString(
+ state.currentActivity !== "idle" ? state.currentActivity : "",
+ state.turnStatus,
+ state.turnId ? "working" : "",
+ ).replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_");
+ }
+ const unread = [
+ entry.hasUnreadTurn, entry.has_unread_turn, entry.unread, entry.isUnread,
+ entry.needsAttention, entry.needs_attention, thread.hasUnreadTurn, thread.has_unread_turn,
+ ].some((value) => value === true || value === 1 || ["true", "1", "yes"].includes(String(value || "").toLowerCase()));
+ let kind = "idle";
+ if (entry.isApproval === true || /approval|permission|request_approval|awaiting_authorization|needs_authorization|requires_action/.test(rawStatus)) kind = "approval";
+ else if (entry.isWaiting === true || /needs?_?input|waiting_for_input|pending_input|pending|queued/.test(rawStatus)) kind = "waiting";
+ else if (entry.isEditing === true || /edit|apply_patch|file_change|writing/.test(rawStatus)) kind = "editing";
+ else if (entry.isThinking === true || /think|reason/.test(rawStatus)) kind = "thinking";
+ else if (entry.isWorking === true || entry.isRunning === true || /run|stream|working|in_progress|active|busy|generat/.test(rawStatus)) kind = "working";
+ else if (/error|fail|cancel|interrupt/.test(rawStatus)) kind = "error";
+ else if (unread) kind = "unread";
+ const labels = {
+ approval: "等待授权",
+ waiting: "等待输入",
+ editing: "编辑中",
+ thinking: "思考中",
+ working: "进行中",
+ error: "异常",
+ unread: "未读",
+ idle: "",
+ };
+ return {
+ kind,
+ label: labels[kind] || "",
+ active: ["approval", "waiting", "editing", "thinking", "working"].includes(kind),
+ attention: ["approval", "waiting", "error", "unread"].includes(kind) || unread,
+ unread,
+ };
+ }
+
+ function sessionEntrySearchText(entry) {
+ if (!isRecord(entry)) return "";
+ const thread = isRecord(entry.thread) ? entry.thread : {};
+ const status = sessionEntryStatus(entry);
+ return [
+ sessionEntryTitle(entry), sessionEntryCwd(entry), sessionEntryId(entry),
+ status.label, entry.mode, entry.source, entry.threadSource, thread.mode,
+ ].filter((value) => typeof value === "string" && value.trim()).join(" ").toLowerCase();
+ }
+
+ function sessionOptionDomId(threadId) {
+ try {
+ return `session-option-${encodeURIComponent(String(threadId)).replace(/%/g, "_")}`;
+ } catch {
+ return `session-option-${String(threadId).replace(/[^a-z0-9_-]/gi, "_")}`;
+ }
+ }
+
+ function sessionEntryIsActive(entry, activeId = "") {
+ if (!isRecord(entry)) return false;
+ const id = sessionEntryId(entry);
+ return id === activeId || entry.active === true || entry.active === 1
+ || ["true", "1", "yes"].includes(String(entry.active || "").toLowerCase());
+ }
+
+ function sessionEntryIsAvailable(entry) {
+ return isRecord(entry)
+ && entry.available !== false
+ && entry.canAttach !== false
+ && entry.attachable !== false;
+ }
+
+ function sessionIsSelectable(entry, activeId, canSwitch) {
+ if (!isRecord(entry)) return false;
+ const id = sessionEntryId(entry);
+ const available = sessionEntryIsAvailable(entry);
+ const current = sessionEntryIsActive(entry, activeId);
+ return Boolean(id && available && !current && canSwitch && !state.sessionSwitching);
+ }
+
+ function filteredSessionEntries() {
+ const source = Array.isArray(state.sessions)
+ ? state.sessions
+ .filter(isRecord)
+ .filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry))
+ .map((entry, index) => ({ entry, index }))
+ : [];
+ source.sort((left, right) => {
+ const rightTime = sessionEntryUpdatedAt(right.entry) ?? 0;
+ const leftTime = sessionEntryUpdatedAt(left.entry) ?? 0;
+ return rightTime - leftTime || left.index - right.index;
+ });
+ const ordered = source.map(({ entry }) => entry);
+ const query = String(state.sessionSearch || "").trim().toLowerCase();
+ return query ? ordered.filter((entry) => sessionEntrySearchText(entry).includes(query)) : ordered;
+ }
+
+ function sessionPathLabel(value) {
+ const text = String(value || "").trim();
+ if (!text) return t("本地会话");
+ const parts = text.split(/[\\/]+/).filter(Boolean);
+ return parts.length > 1 ? `${t("工作区")} · ${parts[parts.length - 1]}` : text;
+ }
+
+ function sessionTimeLabel(value) {
+ const timestamp = timestampMs(value);
+ if (timestamp === null) return "";
+ try {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const day = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
+ const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
+ const difference = Math.round((today - day) / 86_400_000);
+ const locale = uiLocale();
+ const time = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(date);
+ if (difference === 0) return time;
+ if (difference === 1) return `${t("昨天")} ${time}`;
+ if (difference > 1 && difference < 7) return `${new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date)} ${time}`;
+ return `${new Intl.DateTimeFormat(locale, { month: "numeric", day: "numeric" }).format(date)} ${time}`;
+ } catch {
+ return "";
+ }
+ }
+
+ function renderSessionPicker() {
+ const list = $("sessionList");
+ const status = $("sessionPickerStatus");
+ const picker = $("sessionPicker");
+ if (!list || !status || !picker) return;
+ renderControlMode();
+ const activeId = String(state.threadId || state.syncedThreadId || "");
+ const canSwitch = sessionControlAllowed("sessionSelect")
+ && state.appReady
+ && state.ws?.readyState === WebSocket.OPEN
+ && !state.turnId
+ && state.requests.size === 0;
+ const allSessions = Array.isArray(state.sessions)
+ ? state.sessions.filter(isRecord).filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry))
+ : [];
+ const sessions = filteredSessionEntries();
+ const query = String(state.sessionSearch || "").trim();
+ const selectableIds = sessions
+ .filter((entry) => sessionIsSelectable(entry, activeId, canSwitch))
+ .map((entry) => sessionEntryId(entry));
+ if (!selectableIds.includes(state.sessionFocusedId)) {
+ state.sessionFocusedId = selectableIds[0] || "";
+ }
+ const searchInput = $("sessionSearchInput");
+ const searchClear = $("sessionSearchClear");
+ if (searchInput) {
+ if (searchInput.value !== state.sessionSearch) searchInput.value = state.sessionSearch;
+ searchInput.setAttribute("aria-expanded", String(state.sessionPickerOpen));
+ searchInput.setAttribute("aria-activedescendant", state.sessionFocusedId ? sessionOptionDomId(state.sessionFocusedId) : "");
+ }
+ if (searchClear) searchClear.hidden = !query;
+ list.replaceChildren();
+ picker.dataset.switching = String(state.sessionSwitching);
+ list.setAttribute("aria-activedescendant", state.sessionFocusedId ? sessionOptionDomId(state.sessionFocusedId) : "");
+ status.dataset.tone = state.sessionListError ? "warning" : "";
+ if (state.sessionListLoading && !sessions.length) status.textContent = t("正在读取会话…");
+ else if (state.sessionSwitching) {
+ const targetTitle = state.sessionSwitchContext?.targetTitle;
+ status.textContent = targetTitle
+ ? uiLocale() === "en-US" ? `Switching to “${targetTitle}”...` : `正在切换到「${targetTitle}」…`
+ : t("正在切换会话…");
+ }
+ else if (state.sessionListError) status.textContent = t(state.sessionListError);
+ else if (!canSwitch && sessions.length > 1) status.textContent = t("当前任务结束或请求处理后才能切换");
+ else if (query) status.textContent = uiLocale() === "en-US"
+ ? `${sessions.length}/${allSessions.length} conversations`
+ : `${sessions.length}/${allSessions.length} 个会话`;
+ else status.textContent = sessions.length
+ ? (uiLocale() === "en-US" ? `${sessions.length} conversations` : `${sessions.length} 个会话`)
+ : "";
+
+ if (!sessions.length && !state.sessionListLoading) {
+ const empty = document.createElement("div");
+ empty.className = "session-list-empty";
+ const listError = String(state.sessionListError || "");
+ empty.textContent = listError === "等待 VS Code 主机连接"
+ ? t("等待 VS Code 伴随扩展连接")
+ : listError === "VS Code 主机未连接"
+ ? t("VS Code 伴随扩展未连接")
+ : listError === "等待 relay 连接"
+ ? t("等待 relay 连接")
+ : listError
+ ? t("无法读取会话")
+ : query
+ ? t("没有匹配的会话")
+ : t(state.attachMode ? "没有可附加的会话" : "没有可控制的会话");
+ list.append(empty);
+ return;
+ }
+ for (const raw of sessions) {
+ if (!isRecord(raw)) continue;
+ const id = sessionEntryId(raw);
+ if (!id) continue;
+ const title = sessionEntryTitle(raw) || `${t("会话")} ${id.slice(0, 8)}`;
+ const cwd = sessionEntryCwd(raw);
+ const updated = sessionEntryUpdatedAt(raw);
+ const current = sessionEntryIsActive(raw, activeId);
+ const available = sessionEntryIsAvailable(raw);
+ const statusInfo = sessionEntryStatus(raw);
+ const switchingTarget = state.sessionSwitching && id === state.sessionSelectedThreadId;
+ const selectable = sessionIsSelectable(raw, activeId, canSwitch);
+ const option = document.createElement("button");
+ option.type = "button";
+ option.className = "session-option";
+ option.id = sessionOptionDomId(id);
+ option.dataset.available = String(available);
+ option.dataset.threadId = id;
+ option.dataset.status = statusInfo.kind;
+ option.dataset.unread = String(statusInfo.unread);
+ option.dataset.switching = String(switchingTarget);
+ option.dataset.focused = String(id === state.sessionFocusedId);
+ option.setAttribute("role", "option");
+ option.setAttribute("aria-selected", String(current));
+ option.setAttribute("aria-disabled", String(!selectable));
+ option.disabled = !selectable;
+ const titleNode = document.createElement("span");
+ titleNode.className = "session-option-title";
+ titleNode.textContent = title;
+ titleNode.title = title;
+ const timeNode = document.createElement("span");
+ timeNode.className = "session-option-time";
+ timeNode.textContent = sessionTimeLabel(updated);
+ const metaNode = document.createElement("span");
+ metaNode.className = "session-option-meta";
+ metaNode.textContent = sessionPathLabel(cwd);
+ metaNode.title = cwd || title;
+ const stateNode = document.createElement("span");
+ stateNode.className = "session-option-state";
+ const stateLabels = [];
+ if (switchingTarget) stateLabels.push(t("正在切换"));
+ else if (!available) stateLabels.push(t("未打开"));
+ else if (current) stateLabels.push(t("当前"));
+ else if (!statusInfo.label) stateLabels.push(t("可切换"));
+ if (available && statusInfo.label && (!current || statusInfo.active || statusInfo.attention)) stateLabels.push(t(statusInfo.label));
+ const stateDot = document.createElement("span");
+ stateDot.className = "session-status-dot";
+ stateDot.setAttribute("aria-hidden", "true");
+ stateNode.append(stateDot, document.createTextNode(stateLabels.join(" · ")));
+ option.append(titleNode, timeNode, metaNode, stateNode);
+ option.setAttribute("aria-label", `${title}, ${stateLabels.join(", ") || t("会话")}`);
+ option.addEventListener("mouseenter", () => {
+ if (option.disabled) return;
+ state.sessionFocusedId = id;
+ for (const peer of list.querySelectorAll(".session-option")) peer.dataset.focused = String(peer.dataset.threadId === id);
+ list.setAttribute("aria-activedescendant", sessionOptionDomId(id));
+ });
+ if (!option.disabled) option.addEventListener("click", () => {
+ state.sessionFocusedId = id;
+ selectSession(id, title);
+ });
+ list.append(option);
+ }
+ }
+
+ function sessionPickerOptions() {
+ return [...document.querySelectorAll("#sessionList .session-option")]
+ .filter((option) => !option.disabled && option.dataset.threadId);
+ }
+
+ function setSessionFocus(threadId, { scroll = true } = {}) {
+ const id = String(threadId || "");
+ state.sessionFocusedId = id;
+ const list = $("sessionList");
+ if (!list) return;
+ const options = list.querySelectorAll(".session-option");
+ for (const option of options) option.dataset.focused = String(option.dataset.threadId === id);
+ list.setAttribute("aria-activedescendant", id ? sessionOptionDomId(id) : "");
+ $("sessionSearchInput")?.setAttribute("aria-activedescendant", id ? sessionOptionDomId(id) : "");
+ if (scroll) {
+ const target = [...list.querySelectorAll(".session-option")]
+ .find((option) => option.dataset.threadId === id);
+ target?.scrollIntoView?.({ block: "nearest" });
+ }
+ }
+
+ function moveSessionFocus(delta) {
+ const options = sessionPickerOptions();
+ if (!options.length) return false;
+ let index = options.findIndex((option) => option.dataset.threadId === state.sessionFocusedId);
+ if (index < 0) index = delta >= 0 ? -1 : 0;
+ index = (index + delta + options.length) % options.length;
+ setSessionFocus(options[index].dataset.threadId);
+ return true;
+ }
+
+ function activateFocusedSession() {
+ const id = state.sessionFocusedId;
+ if (!id) return false;
+ const entry = (Array.isArray(state.sessions) ? state.sessions : [])
+ .find((candidate) => sessionEntryId(candidate) === id);
+ if (!entry) return false;
+ const activeId = String(state.threadId || state.syncedThreadId || "");
+ const canSwitch = !state.turnId && state.requests.size === 0;
+ if (!sessionIsSelectable(entry, activeId, canSwitch)) return false;
+ selectSession(id, sessionEntryTitle(entry));
+ return true;
+ }
+
+ function handleSessionPickerKeydown(event) {
+ if (!state.sessionPickerOpen) return;
+ if (event.key === "ArrowDown") {
+ if (moveSessionFocus(1)) event.preventDefault();
+ return;
+ }
+ if (event.key === "ArrowUp") {
+ if (moveSessionFocus(-1)) event.preventDefault();
+ return;
+ }
+ if (event.key === "Home") {
+ const options = sessionPickerOptions();
+ if (options.length) {
+ setSessionFocus(options[0].dataset.threadId);
+ event.preventDefault();
+ }
+ return;
+ }
+ if (event.key === "End") {
+ const options = sessionPickerOptions();
+ if (options.length) {
+ setSessionFocus(options[options.length - 1].dataset.threadId);
+ event.preventDefault();
+ }
+ return;
+ }
+ if (event.key === "Enter") {
+ if (activateFocusedSession()) event.preventDefault();
+ return;
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setSessionPicker(false);
+ }
+ }
+
+ function setSessionPicker(open) {
+ const picker = $("sessionPicker");
+ const button = $("sessionPickerButton");
+ if (!picker || !button) return;
+ const next = Boolean(open) && sessionControlAllowed("sessionList") && !state.modeSwitching;
+ picker.hidden = !next;
+ state.sessionPickerOpen = next;
+ button.setAttribute("aria-expanded", String(next));
+ if (next) {
+ state.sessionSearch = "";
+ state.sessionFocusedId = "";
+ $("panelMenu").hidden = true;
+ $("detailsPopover").hidden = true;
+ renderSessionPicker();
+ requestSessionList();
+ scheduleFrame(() => {
+ if (state.sessionPickerOpen) $("sessionSearchInput")?.focus();
+ });
+ } else {
+ state.sessionSearch = "";
+ state.sessionFocusedId = "";
+ const input = $("sessionSearchInput");
+ if (input) {
+ input.value = "";
+ input.setAttribute("aria-expanded", "false");
+ input.setAttribute("aria-activedescendant", "");
+ }
+ const clear = $("sessionSearchClear");
+ if (clear) clear.hidden = true;
+ if (picker.contains(document.activeElement)) button.focus();
+ }
+ }
+
+ function openSessionHistory() {
+ if (!sessionControlAllowed("sessionList")) return;
+ setSessionPicker(true);
+ }
+
+ function requestControlMode(value) {
+ const mode = normalizeControlMode(value);
+ if (!mode || mode === state.controlMode || controlModeChangeBlocked()) return;
+ closePopovers();
+ state.modeSwitching = true;
+ state.requestedControlMode = mode;
+ state.modeRequestEpoch = state.modeEpoch;
+ setConversationStatus("正在切换控制模式", "active");
+ updateIds();
+ try {
+ state.modeCommandId = command("control/mode/set", { mode });
+ } catch (error) {
+ clearControlModeRequest();
+ setConversationStatus(error?.message || "控制模式切换失败", "warning");
+ updateIds();
+ }
+ }
+
+ function requestNewSession() {
+ closePopovers();
+ if (!sessionControlAllowed("sessionCreate")) {
+ setConversationStatus("同步模式下会话管理由 VS Code 控制", "warning");
+ return;
+ }
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
+ setConversationStatus("等待 relay 连接", "warning");
+ return;
+ }
+ if (!state.appReady) {
+ setConversationStatus("等待 VS Code 主机连接", "warning");
+ return;
+ }
+ if (state.role !== "operator" && state.role !== "owner" && state.role !== "host") {
+ setConversationStatus("当前角色不能创建会话", "warning");
+ return;
+ }
+ if (state.newSessionCommandId) return;
+ try {
+ state.newSessionCommandId = command("session/new", {});
+ setConversationStatus("正在创建新会话", "active");
+ updateIds();
+ } catch (error) {
+ state.newSessionCommandId = "";
+ setConversationStatus(sessionErrorMessage(error, "无法创建新会话"), "warning");
+ updateIds();
+ }
+ }
+
+ function requestSessionList() {
+ if (!sessionControlAllowed("sessionList")) return;
+ if (state.sessionListLoading) {
+ renderSessionPicker();
+ return;
+ }
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
+ state.sessionListError = "等待 relay 连接";
+ renderSessionPicker();
+ return;
+ }
+ if (!state.appReady) {
+ state.sessionListError = "等待 VS Code 主机连接";
+ renderSessionPicker();
+ return;
+ }
+ state.sessionListLoading = true;
+ state.sessionListError = "";
+ renderSessionPicker();
+ try {
+ state.sessionListCommandId = command("session/list", {});
+ } catch (error) {
+ state.sessionListLoading = false;
+ state.sessionListError = sessionErrorMessage(error, "无法读取会话");
+ renderSessionPicker();
+ }
+ }
+
+ function selectSession(threadId, title = "") {
+ const id = String(threadId || "").trim();
+ if (!sessionControlAllowed("sessionSelect")) return;
+ if (!id || state.sessionSwitching || id === state.threadId) return;
+ if (state.turnId || state.requests.size) {
+ state.sessionListError = "当前任务仍在运行或等待授权,暂不能切换";
+ renderSessionPicker();
+ return;
+ }
+ const context = beginSessionSwitchContext(id, title);
+ state.sessionSwitching = true;
+ state.sessionListError = "";
+ setSessionSwitchingVisual(true);
+ renderSessionPicker();
+ try {
+ state.sessionSelectCommandId = command("session/select", { threadId: id });
+ setConversationStatus(
+ context.targetTitle
+ ? uiLocale() === "en-US" ? `Switching to “${context.targetTitle}”` : `正在切换到「${context.targetTitle}」`
+ : t("正在切换会话"),
+ "active",
+ );
+ } catch (error) {
+ restoreSessionSwitchContext();
+ state.sessionListError = sessionErrorMessage(error, "会话切换失败");
+ renderSessionPicker();
+ }
+ }
+
+ function applySessionListResult(result) {
+ const body = isRecord(result) ? result : {};
+ const source = Array.isArray(result) ? result : Array.isArray(body.sessions) ? body.sessions : Array.isArray(body.threads) ? body.threads : [];
+ const activeId = firstString(body.activeThreadId, body.threadId);
+ state.sessions = source
+ .filter((entry) => isRecord(entry))
+ .filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry))
+ .map((entry) => ({ ...entry }));
+ if (activeId) state.sessions = state.sessions.map((entry) => ({ ...entry, active: sessionEntryId(entry) === activeId || entry.active === true }));
+ state.sessionListLoading = false;
+ state.sessionListCommandId = "";
+ state.sessionListError = "";
+ renderSessionPicker();
+ }
+
+ function applySessionSelectResult(result) {
+ const body = isRecord(result) ? result : {};
+ const selected = firstString(body.threadId, body.activeThreadId);
+ state.sessionSelectedThreadId = selected;
+ if (selected && state.sessions.length) {
+ state.sessions = state.sessions.map((entry) => ({ ...entry, active: sessionEntryId(entry) === selected }));
+ }
+ // A command result confirms only that the command completed. The switch
+ // itself stays fenced until both the sequenced `session.selected` event
+ // and the target's authoritative projection have independently arrived.
+ // This also keeps automatic VS Code navigation and picker navigation on
+ // the same completion path.
+ if (state.sessionSwitchContext) {
+ state.sessionSwitching = true;
+ setSessionSwitchingVisual(true);
+ const context = state.sessionSwitchContext;
+ setConversationStatus(
+ context.targetSnapshotReady ? "正在确认会话" : "正在加载会话",
+ "active",
+ );
+ } else {
+ state.sessionSwitching = false;
+ state.sessionSelectCommandId = "";
+ finishSessionSwitchContext();
+ }
+ renderSessionPicker();
+ if (!state.sessionSwitching) {
+ setSessionPicker(false);
+ setConversationStatus("会话已切换", "ready");
+ }
+ requestRefresh();
+ }
+
+ function syncSessionActive(threadId) {
+ const activeId = String(threadId || "");
+ if (!activeId || !Array.isArray(state.sessions) || !state.sessions.length) return;
+ state.sessions = state.sessions.map((entry) => ({
+ ...entry,
+ active: sessionEntryId(entry) === activeId,
+ }));
+ renderSessionPicker();
+ }
+
+ function messageText(item) {
+ if (!isRecord(item)) return "";
+ const direct = [item.text, item.content, item.message, item.summary, item.output]
+ .find((value) => typeof value === "string" && value.length);
+ if (direct) return direct;
+ if (Array.isArray(item.content)) {
+ return item.content.map((part) => {
+ if (typeof part === "string") return part;
+ if (!isRecord(part)) return "";
+ return part.text || part.content || part.value || "";
+ }).filter(Boolean).join("\n");
+ }
+ // Official collaboration records are often state-only projections: they
+ // carry an agent id/name and lifecycle kind, but no user-facing `text`.
+ // Keep those rows in the transcript so the sub-agent disclosure and panel
+ // can be hydrated from the same snapshot.
+ if (historyKind(item) === "subagent") {
+ const agent = isRecord(item.agent) ? item.agent : {};
+ const name = firstString(
+ item.displayName,
+ item.agentNickname,
+ item.agentName,
+ item.agentPath,
+ agent.displayName,
+ agent.name,
+ item.agentThreadId ? `thread ${item.agentThreadId}` : "子代理",
+ );
+ const objective = firstString(
+ item.objective,
+ item.prompt,
+ item.statusMessage,
+ item.description,
+ agent.objective,
+ agent.prompt,
+ );
+ const action = firstString(item.action, item.tool, item.activityKind, agent.action);
+ const rawStatus = firstString(item.displayStatus, item.status, item.state, agent.status);
+ const normalizedStatus = normalizeSubagentStatus(rawStatus);
+ const statusText = subagentStatusLabel(normalizedStatus);
+ if (objective && name) return `${name}:${objective}`;
+ if (action && name) return `${name} · ${action}`;
+ if (name) return `${name} · ${statusText}`;
+ return `子代理 · ${statusText}`;
+ }
+ if (isReadActivity(item)) {
+ return firstString(...readPathList(item), commandText(item), "读取文件");
+ }
+ if (/(?:command|exec|process|tool)/i.test(String(item.type || item.kind || ""))) {
+ return firstString(commandText(item), "工具输出");
+ }
+ const fallback = [item.reasoning, item.plan, item.steps, item.diff, item.patch, item.description]
+ .map((value) => displayValue(value))
+ .find((value) => value);
+ if (fallback) return fallback;
+ return "";
+ }
+
+ function historyKind(item) {
+ const sourceKind = String(item?.kind || item?.type || "assistant").toLowerCase();
+ if (item?.uiType === "subagent-activity" || item?.uiType === "multi-agent-action"
+ || sourceKind.includes("subagent") || sourceKind.includes("collabagent")) return "subagent";
+ if (item?.role === "user" || sourceKind.includes("user")) return "user";
+ if (isReadActivity(item)) return "read";
+ if (sourceKind.includes("edit") || sourceKind.includes("filechange") || sourceKind.includes("patch")) return "edit";
+ if (sourceKind.includes("tool") || sourceKind.includes("command") || sourceKind.includes("exec") || sourceKind.includes("process")
+ || sourceKind.includes("websearch") || sourceKind.includes("mcp") || sourceKind.includes("imageview")
+ || sourceKind.includes("generatedimage") || sourceKind.includes("dynamictool")
+ || sourceKind.includes("permissionrequest") || sourceKind.includes("userinput")) return "tool";
+ if (sourceKind.includes("reasoning") || sourceKind.includes("contextcompaction") || sourceKind.includes("approvalreview")) return "reasoning";
+ if (sourceKind.includes("plan") || sourceKind.includes("todo")) return "plan";
+ return "assistant";
+ }
+
+ function normalizedAssistantPhase(item) {
+ return String(item?.phase || item?.messagePhase || "")
+ .replace(/[\s-]+/g, "_")
+ .toLowerCase();
+ }
+
+ // Agent commentary is a work item in the official transcript. It belongs
+ // inside the worked-for disclosure, while the final answer stays outside it.
+ // Older snapshots omit `phase`, so use the last item in the turn as the
+ // fallback final answer boundary.
+ function isAssistantCommentary(item, index, turnKey, turnLastIndex) {
+ if (historyKind(item) !== "assistant") return false;
+ const phase = normalizedAssistantPhase(item);
+ if (phase === "final_answer" || phase === "finalanswer") return false;
+ if (phase === "commentary" || phase === "analysis" || phase === "reasoning" || phase === "thinking") return true;
+ return turnLastIndex instanceof Map && turnLastIndex.get(turnKey) !== index;
+ }
+
+ function structuredTurnFinalAssistantIndexes(messages) {
+ const result = new Map();
+ const explicit = new Set();
+ if (!Array.isArray(messages)) return result;
+ messages.forEach((item, index) => {
+ if (!isRecord(item) || historyKind(item) !== "assistant") return;
+ const key = structuredMessageTurn(item, index);
+ const phase = normalizedAssistantPhase(item);
+ if (phase === "final_answer" || phase === "finalanswer") {
+ result.set(key, index);
+ explicit.add(key);
+ } else if (!explicit.has(key)) {
+ // A bookkeeping/sub-agent item can follow the answer in newer
+ // snapshots. Choose the last assistant message, not the last item.
+ result.set(key, index);
+ }
+ });
+ return result;
+ }
+
+ function structuredDisplayKind(item, index, turnKey, turnLastIndex) {
+ const kind = historyKind(item);
+ return kind === "assistant" && isAssistantCommentary(item, index, turnKey, turnLastIndex)
+ ? "commentary"
+ : kind;
+ }
+
+ function isCollapsibleKind(kind) {
+ return kind === "tool" || kind === "read" || kind === "edit" || kind === "reasoning"
+ || kind === "plan" || kind === "subagent" || kind === "commentary";
+ }
+
+ function isReadActivity(item) {
+ if (!isRecord(item)) return false;
+ const type = normalizedItemType(item);
+ const semantic = String(item.activityKind || item.uiType || item.operation || item.commandType || "")
+ .replace(/[\s_./-]+/g, "")
+ .toLowerCase();
+ const parsed = item.parsedCmd || item.parsedCommand || item.parsedCommandType;
+ const parsedType = typeof parsed === "string"
+ ? parsed
+ : isRecord(parsed) ? firstString(parsed.type, parsed.kind, parsed.operation) : "";
+ const parsedNormalized = String(parsedType).replace(/[\s_./-]+/g, "").toLowerCase();
+ return semantic.includes("fileread") || semantic.includes("readfile") || semantic === "read"
+ || type.includes("fileread") || type.includes("readfile") || type === "read"
+ || parsedNormalized === "read" || parsedNormalized === "fileread";
+ }
+
+ function readPathList(item) {
+ if (!isRecord(item)) return [];
+ const paths = [];
+ const visit = (value) => {
+ if (typeof value === "string" && value.trim()) paths.push(value.trim());
+ else if (Array.isArray(value)) value.forEach(visit);
+ else if (isRecord(value)) visit(value.path ?? value.file ?? value.filePath ?? value.name);
+ };
+ [item.path, item.file, item.filePath, item.filename, item.name, item.readPath, item.readPaths, item.files, item.paths].forEach(visit);
+ return [...new Set(paths)];
+ }
+
+ function readSummaryLabel(item, status, duration = "") {
+ const paths = [...new Set(readPathList(item).flatMap((value) => String(value).split(/\r?\n/).map((part) => part.trim()).filter(Boolean)))];
+ const suffix = duration ? ` · ${duration}` : "";
+ if (status === "inProgress") return paths.length === 1
+ ? uiWithRaw("正在读取 ", "Reading ", paths[0])
+ : t("正在读取文件");
+ if (status === "failed") return paths.length === 1
+ ? `${uiWithRaw("读取失败 · ", "Failed to read ", paths[0])}${suffix}`
+ : `${t("读取文件失败")}${suffix}`;
+ if (status === "interrupted") return paths.length === 1
+ ? `${uiWithRaw("已停止读取 ", "Stopped reading ", paths[0])}${suffix}`
+ : `${t("已停止读取文件")}${suffix}`;
+ if (paths.length === 1) return `${uiWithRaw("已读取 ", "Read ", paths[0])}${suffix}`;
+ if (paths.length > 1) {
+ const count = String(paths.length);
+ return `${uiText("已读取这些内容 · ", "Read these items · ")}${count}${uiText(" 个文件", " files")}${suffix}`;
+ }
+ return `${t("已读取文件")}${suffix}`;
+ }
+
+ function structuredMessageKey(item, index) {
+ const id = item?.id ?? item?.itemId;
+ if (id !== undefined && id !== null && String(id)) return `id:${String(id)}`;
+ const turn = item?.turnId ? String(item.turnId) : "item";
+ return `turn:${turn}:${historyKind(item)}:${index}`;
+ }
+
+ function structuredMessageRole(item, kind) {
+ return item.role === "user" || kind === "user"
+ ? "user"
+ : item.role === "tool" || kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent"
+ ? "tool"
+ : item.role === "reasoning" || kind === "reasoning" || kind === "plan"
+ ? "system"
+ : item.role === "error"
+ ? "error"
+ : "assistant";
+ }
+
+ function structuredMessageStatus(item) {
+ if (item.status !== undefined && item.status !== null && item.status !== "") {
+ return normalizeActivityStatus(item.status, String(item.status));
+ }
+ if (item.completed === true) return "completed";
+ if (item.completed === false) return "inProgress";
+ if (item.turnStatus !== undefined && item.turnStatus !== null && item.turnStatus !== "") {
+ return normalizeActivityStatus(item.turnStatus, String(item.turnStatus));
+ }
+ return "";
+ }
+
+ function explicitStructuredTurnId(item) {
+ if (!isRecord(item)) return "";
+ const nestedTurn = isRecord(item.turn) ? item.turn : {};
+ const value = [
+ item.turnId,
+ item.conversationTurnId,
+ item.conversation_turn_id,
+ item.turn_id,
+ nestedTurn.id,
+ nestedTurn.turnId,
+ ].find((candidate) => candidate !== undefined && candidate !== null && String(candidate).trim());
+ return value === undefined ? "" : String(value);
+ }
+
+ function deriveStructuredTurnKeys(messages) {
+ if (!Array.isArray(messages)) return new Map();
+ const keys = new Map();
+ let anonymousNumber = 0;
+ let currentKey = "";
+ let currentHasUser = false;
+ let currentEnded = false;
+ const makeAnonymousKey = () => `anonymous:${anonymousNumber++}`;
+ messages.forEach((item, index) => {
+ if (!isRecord(item)) return;
+ const explicit = explicitStructuredTurnId(item);
+ const kind = historyKind(item);
+ if (explicit) {
+ currentKey = explicit;
+ currentHasUser = kind === "user";
+ currentEnded = false;
+ keys.set(item, currentKey);
+ return;
+ }
+ if (!currentKey || kind === "user" && (currentHasUser || currentEnded)) {
+ currentKey = makeAnonymousKey();
+ currentHasUser = false;
+ currentEnded = false;
+ }
+ if (kind === "user") currentHasUser = true;
+ keys.set(item, currentKey);
+ // A final-answer marker is the only reliable boundary in a legacy
+ // projection. Do not use turnStatus here: adapters attach the terminal
+ // turn status to every item in the turn.
+ const phase = normalizedAssistantPhase(item);
+ if (kind === "assistant" && (phase === "final_answer" || phase === "finalanswer")) currentEnded = true;
+ // Keep the index in the map as a debugging fallback for primitive array
+ // entries, while object identity remains the canonical lookup.
+ if (index < 0) keys.set(item, currentKey);
+ });
+ state.structuredTurnKeys = new WeakMap();
+ for (const [item, key] of keys) state.structuredTurnKeys.set(item, key);
+ return keys;
+ }
+
+ function structuredMessageTurn(item, index) {
+ const explicit = explicitStructuredTurnId(item);
+ if (explicit) return explicit;
+ if (isRecord(item)) {
+ const derived = state.structuredTurnKeys?.get(item);
+ if (derived) return derived;
+ }
+ return `item:${index}`;
+ }
+
+ function structuredActivityKey(item, kind, turnKey, index) {
+ const itemId = item?.itemId ?? item?.id;
+ const threadKey = item?.threadId || state.threadId || "thread";
+ if (itemId !== undefined && itemId !== null && String(itemId)) {
+ return `${threadKey}:${turnKey || "turn"}:${kind}:${typeof itemId}:${String(itemId)}`;
+ }
+ return `structured:${structuredMessageKey(item, index)}`;
+ }
+
+ function structuredTurnWorkedDurations(messages) {
+ const result = new Map();
+ const explicitKeys = new Set();
+ const starts = new Map();
+ const ends = new Map();
+ if (!Array.isArray(messages)) return result;
+ deriveStructuredTurnKeys(messages);
+ const finalAssistantIndexes = structuredTurnFinalAssistantIndexes(messages);
+ messages.forEach((item, index) => {
+ const key = structuredMessageTurn(item, index);
+ const explicit = finiteNumber(
+ item?.workedDurationMs,
+ item?.workDurationMs,
+ item?.workedForMs,
+ item?.turnWorkedDurationMs,
+ item?.workedFor?.durationMs,
+ );
+ if (explicit !== null) {
+ result.set(key, Math.max(0, explicit));
+ explicitKeys.add(key);
+ }
+ const workStart = timestampMs(
+ item?.firstTurnWorkItemStartedAtMs,
+ item?.workStartedAtMs,
+ item?.turnWorkStartedAtMs,
+ item?.turnStartedAtMs,
+ item?.workedFor?.startedAtMs,
+ );
+ if (workStart !== null && !starts.has(key)) starts.set(key, workStart);
+ const kind = historyKind(item);
+ if (kind !== "user") {
+ const itemStart = timestampMs(item?.startedAtMs, item?.startedAt, item?.createdAtMs);
+ if (itemStart !== null && kind !== "assistant") {
+ // Activity timestamps are the closest equivalent to the official
+ // first-turn-work-item marker in older follower snapshots.
+ if (!starts.has(key)) starts.set(key, itemStart);
+ }
+ }
+ const explicitAssistantStart = timestampMs(
+ item?.finalAssistantStartedAtMs,
+ item?.workedFor?.completedAtMs,
+ );
+ // The official worked-for clock ends when the final assistant response
+ // starts. Earlier assistant commentary is part of the work group and
+ // must not extend the duration; this was the source of the recurring
+ // three-to-five-second discrepancy in legacy snapshots.
+ const isFinalAssistant = kind === "assistant" && finalAssistantIndexes.get(key) === index;
+ const assistantStart = explicitAssistantStart !== null
+ ? explicitAssistantStart
+ : isFinalAssistant ? timestampMs(item?.startedAtMs, item?.startedAt, item?.createdAtMs) : null;
+ if (assistantStart !== null) ends.set(key, Math.max(ends.get(key) || 0, assistantStart));
+ });
+ for (const [key, start] of starts) {
+ if (result.has(key)) continue;
+ const end = ends.get(key);
+ if (end !== undefined && end >= start) result.set(key, end - start);
+ }
+ // Some older host snapshots keep the authoritative worked-for duration in
+ // session metadata rather than repeating it on each projected item. Apply
+ // it to the most recent turn only; explicit per-item values always win.
+ const metadataDuration = finiteNumber(state.workedDurationMs, state.lastWorkedDurationMs);
+ if (metadataDuration !== null && messages.length) {
+ const keysInOrder = messages
+ .filter(isRecord)
+ .map((item, index) => structuredMessageTurn(item, index));
+ const target = state.turnId && keysInOrder.includes(state.turnId)
+ ? state.turnId
+ : keysInOrder.at(-1);
+ // Session metadata is authoritative over timestamp inference, but an
+ // explicit per-turn/item value from the host remains the strongest
+ // signal.
+ if (target && !explicitKeys.has(target)) result.set(target, Math.max(0, metadataDuration));
+ }
+ // Older attach snapshots may expose the final-answer start separately
+ // from the duration. Use it only for the corresponding most recent turn.
+ const metadataFinal = timestampMs(state.finalAssistantStartedAt);
+ if (metadataFinal !== null && messages.length) {
+ const keyed = messages.filter(isRecord).map((item, index) => structuredMessageTurn(item, index));
+ const target = state.turnId && keyed.includes(state.turnId) ? state.turnId : keyed.at(-1);
+ const start = target ? starts.get(target) : null;
+ if (target && start !== undefined && !explicitKeys.has(target) && !result.has(target) && metadataFinal >= start) {
+ result.set(target, metadataFinal - start);
+ }
+ }
+ return result;
+ }
+
+ function structuredActivityTiming(messages, index, turnKey, status) {
+ const item = Array.isArray(messages) ? messages[index] : null;
+ if (!isRecord(item)) return { durationMs: null, finishedAt: null };
+ const explicitDuration = finiteNumber(item.durationMs, item.elapsedMs);
+ const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs);
+ const explicitFinishedAt = timestampMs(item.completedAtMs, item.completedAt, item.finishedAtMs, item.finishedAt);
+ if (explicitDuration !== null) {
+ return {
+ durationMs: explicitDuration,
+ finishedAt: explicitFinishedAt ?? (startedAt === null ? null : startedAt + explicitDuration),
+ };
+ }
+ if (explicitFinishedAt !== null && startedAt !== null) {
+ return { durationMs: Math.max(0, explicitFinishedAt - startedAt), finishedAt: explicitFinishedAt };
+ }
+ // Hydrated legacy rows sometimes expose only start times. The next item in
+ // the same turn is the closest end marker (and matches the official
+ // projection's item interval). Never infer an end for an explicitly live
+ // row, and never let a later turn's timestamp inflate this activity.
+ if (status !== "inProgress" && startedAt !== null && Array.isArray(messages)) {
+ for (let cursor = index + 1; cursor < messages.length; cursor += 1) {
+ const next = messages[cursor];
+ if (!isRecord(next)) continue;
+ if (structuredMessageTurn(next, cursor) !== turnKey) break;
+ const nextStartedAt = timestampMs(next.startedAtMs, next.startedAt, next.createdAtMs);
+ if (nextStartedAt === null) continue;
+ if (nextStartedAt >= startedAt) return {
+ durationMs: nextStartedAt - startedAt,
+ finishedAt: nextStartedAt,
+ };
+ break;
+ }
+ }
+ return { durationMs: null, finishedAt: explicitFinishedAt };
+ }
+
+ function indexStructuredActivity(article, item, kind, turnKey, index, messages = null) {
+ if (!article || !isCollapsibleKind(kind)) return null;
+ const key = article.dataset.activityKey || structuredActivityKey(item, kind, turnKey, index);
+ article.dataset.activityKey = key;
+ article.dataset.activityKind = kind;
+ const existing = state.activities.get(key);
+ const turnStatus = normalizeActivityStatus(item.turnStatus, "");
+ const status = structuredMessageStatus(item) || (turnStatus === "inProgress" ? "inProgress" : "completed");
+ const timing = structuredActivityTiming(messages, index, turnKey, status);
+ const durationMs = timing.durationMs;
+ const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs);
+ const activity = existing || {
+ key,
+ kind,
+ role: kind === "commentary"
+ ? "assistant"
+ : kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" ? "tool" : "system",
+ messageKind: kind === "tool" || kind === "read" ? "tool" : kind,
+ label: item.label || activityLabelForItem(item, kind),
+ command: kind === "tool" || kind === "read" ? terminalCommandText(commandText(item)) : commandText(item),
+ filePath: kind === "read" ? readPathList(item).join("\n") : "",
+ cwd: (kind === "tool" || kind === "read") && typeof item.cwd === "string" ? item.cwd : "",
+ shellName: (kind === "tool" || kind === "read") && typeof item.shellName === "string" && item.shellName ? item.shellName : "Shell",
+ agentThreadId: kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : "",
+ displayName: kind === "subagent" ? firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath) : "",
+ objective: kind === "subagent" ? firstString(item.objective, item.prompt, item.statusMessage, item.message) : "",
+ activityKind: kind === "subagent" ? firstString(item.activityKind, item.kind) : "",
+ displayStatus: kind === "subagent" ? firstString(item.displayStatus, item.status) : "",
+ model: kind === "subagent" ? firstString(item.model, item.modelId) : "",
+ action: kind === "subagent" ? firstString(item.action, item.tool) : "",
+ prompt: kind === "subagent" && item.prompt !== null ? firstString(item.prompt) : "",
+ senderThreadId: kind === "subagent" ? firstString(item.senderThreadId) : "",
+ receiverThreadIds: kind === "subagent"
+ ? Array.isArray(item.receiverThreadIds)
+ ? item.receiverThreadIds.map(String)
+ : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : []
+ : [],
+ agentsStates: kind === "subagent" && isRecord(item.agentsStates) ? item.agentsStates : {},
+ canInteract: item.canInteract !== false,
+ exitCode: kind === "tool" || kind === "read" ? finiteNumber(item.exitCode, item.exit_code) : null,
+ itemId: item.itemId === undefined || item.itemId === null ? "" : String(item.itemId),
+ threadId: item.threadId || state.threadId || "",
+ turnId: turnKey || "",
+ startedAt: startedAt || null,
+ finishedAt: null,
+ durationMs: null,
+ durationExplicit: finiteNumber(item.durationMs, item.elapsedMs) !== null,
+ status,
+ headerText: "",
+ outputText: "",
+ anonymous: false,
+ concrete: true,
+ statusOnly: false,
+ article,
+ body: article.querySelector(".details-body") || article.querySelector(".message-body"),
+ wrapper: article.querySelector(".message-content"),
+ details: article.querySelector("details"),
+ summary: article.querySelector("summary"),
+ };
+ activity.key = key;
+ activity.kind = kind;
+ activity.messageKind = kind === "tool" || kind === "read" ? "tool" : kind;
+ activity.itemId = item.itemId === undefined || item.itemId === null ? activity.itemId || "" : String(item.itemId);
+ // Structured history is an authoritative concrete projection. If a live
+ // status row occupied the same slot during reconnect, promote it here so
+ // the next terminal update does not discard the hydrated item.
+ activity.concrete = true;
+ activity.statusOnly = false;
+ activity.anonymous = false;
+ activity.turnId = turnKey || activity.turnId || "";
+ activity.threadId = item.threadId || activity.threadId || state.threadId || "";
+ activity.label = item.label || activity.label || activityLabelForItem(item, kind);
+ activity.command = kind === "tool" || kind === "read"
+ ? terminalCommandText(commandText(item)) || activity.command || ""
+ : commandText(item) || activity.command || "";
+ if (kind === "tool" || kind === "read") {
+ if (kind === "read") activity.filePath = readPathList(item).join("\n") || activity.filePath;
+ activity.cwd = typeof item.cwd === "string" ? item.cwd : activity.cwd || "";
+ activity.shellName = typeof item.shellName === "string" && item.shellName
+ ? item.shellName
+ : activity.shellName || "Shell";
+ activity.exitCode = item.exitCode === undefined || item.exitCode === null
+ ? activity.exitCode ?? null
+ : finiteNumber(item.exitCode, item.exit_code);
+ }
+ if (kind === "subagent") {
+ activity.agentThreadId = firstString(item.agentThreadId, item.childThreadId, item.threadId, activity.agentThreadId);
+ activity.displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, activity.displayName);
+ activity.objective = firstString(item.objective, item.prompt, item.statusMessage, item.message, activity.objective);
+ activity.activityKind = firstString(item.activityKind, item.kind, activity.activityKind);
+ activity.displayStatus = firstString(item.displayStatus, item.status, activity.displayStatus);
+ activity.model = firstString(item.model, item.modelId, activity.model);
+ activity.action = firstString(item.action, item.tool, activity.action);
+ if (item.prompt !== null && item.prompt !== undefined) activity.prompt = firstString(item.prompt, activity.prompt);
+ activity.senderThreadId = firstString(item.senderThreadId, activity.senderThreadId);
+ if (Array.isArray(item.receiverThreadIds)) activity.receiverThreadIds = item.receiverThreadIds.map(String);
+ else if (Array.isArray(item.receiverThreads)) activity.receiverThreadIds = item.receiverThreads.map(String);
+ if (isRecord(item.agentsStates)) activity.agentsStates = item.agentsStates;
+ if (item.canInteract !== undefined) activity.canInteract = item.canInteract !== false;
+ if (activity.agentThreadId) article.dataset.agentThreadId = activity.agentThreadId;
+ else delete article.dataset.agentThreadId;
+ }
+ activity.startedAt = startedAt || activity.startedAt || null;
+ if (durationMs !== null || !existing) activity.durationMs = durationMs;
+ activity.durationExplicit = finiteNumber(item.durationMs, item.elapsedMs) !== null;
+ activity.status = status;
+ activity.finishedAt = timing.finishedAt || activity.finishedAt || null;
+ activity.article = article;
+ activity.body = article.querySelector(".details-body") || article.querySelector(".message-body");
+ activity.wrapper = article.querySelector(".message-content");
+ activity.details = article.querySelector("details");
+ activity.summary = article.querySelector("summary");
+ activity.outputText = kind === "tool" || kind === "read" ? activityOutput(item, kind) : messageText(item);
+ state.activities.set(key, activity);
+ // Hydrated rows may have been created before lifecycle metadata arrived.
+ // Recompute the disclosure label from the normalized status/timestamps so
+ // history and live updates cannot leave stale `0ms` or generic text behind.
+ renderActivityText(activity);
+ refreshActivity(activity);
+ return activity;
+ }
+
+ function updateStructuredMessageArticle(article, item, index, turnLastIndex, turnHasActivity = new Set(), turnWorkedDurations = new Map()) {
+ const itemText = messageText(item);
+ if (!itemText) return false;
+ const sourceKind = historyKind(item);
+ const turnKey = structuredMessageTurn(item, index);
+ const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex);
+ const role = structuredMessageRole(item, kind);
+ const status = structuredMessageStatus(item) || "completed";
+ const durationMs = item.durationMs ?? item.elapsedMs;
+ const duration = elapsedDuration(durationMs);
+ const body = article.querySelector(".message-body");
+ if (!body) return false;
+ const details = article.querySelector("details");
+ const wasOpen = details ? isDetailsExpanded(details) : undefined;
+ article.dataset.rawText = String(itemText);
+ article.dataset.turnId = turnKey;
+ article.dataset.kind = kind;
+ if (item.itemId !== undefined && item.itemId !== null) article.dataset.itemId = String(item.itemId);
+ if (item.itemType) article.dataset.itemType = String(item.itemType);
+ const agentThreadId = kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : "";
+ if (agentThreadId) article.dataset.agentThreadId = agentThreadId;
+ else delete article.dataset.agentThreadId;
+ if (item.startedAtMs || item.completedAtMs) {
+ const parsed = timestampMs(item.startedAtMs ?? item.completedAtMs);
+ if (parsed !== null) article.dataset.timestamp = String(parsed);
+ }
+ if (status) article.dataset.status = status;
+ const isActivity = isCollapsibleKind(kind);
+ article.classList.toggle("streaming", status === "inProgress" && !isActivity);
+ renderMessageBody(body, itemText, role, isActivity ? "activity" : "history", kind);
+ if (details) {
+ const summary = details.querySelector("summary");
+ const activitySummary = historyActivitySummary(item, kind, status, duration);
+ if (summary && activitySummary) setActivitySummary(summary, activitySummary, kind);
+ if (wasOpen !== undefined) setDetailsExpanded(details, wasOpen, { immediate: true, preserve: false });
+ }
+ const finalAssistant = sourceKind === "assistant" && role === "assistant" && kind === "assistant"
+ && (item.phase === "final_answer" || item.phase === "final-answer" || !isAssistantCommentary(item, index, turnKey, turnLastIndex));
+ if (finalAssistant && status !== "inProgress" && !article.classList.contains("streaming") && !article.querySelector(".message-actions")) {
+ addMessageActions(article, true);
+ }
+ const terminalTurn = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"]
+ .includes(String(item.turnStatus || "").replace(/[\s-]+/g, "_").toLowerCase());
+ const workedDuration = turnWorkedDurations.get(turnKey)
+ ?? workedDurationFor(item, null);
+ if (finalAssistant && (terminalTurn || workedDuration !== null || (durationMs !== undefined && status !== "inProgress"))) {
+ appendTurnDivider(turnKey, item.turnStatus || status || "completed", workedDuration ?? durationMs, article);
+ }
+ return true;
+ }
+
+ function reconcileStructuredOutput(text, structuredMessages) {
+ const output = $("output");
+ if (!output || !Array.isArray(structuredMessages) || !structuredMessages.length) return false;
+ const articles = [...output.querySelectorAll(".message[data-structured-key]")];
+ if (articles.length !== structuredMessages.length) return false;
+ const keys = structuredMessages.map((item, index) => structuredMessageKey(item, index));
+ if (articles.some((article, index) => article.dataset.structuredKey !== keys[index])) return false;
+ const follow = shouldFollowOutput(output);
+ const turnLastIndex = structuredTurnFinalAssistantIndexes(structuredMessages);
+ const turnHasActivity = new Set();
+ const turnWorkedDurations = structuredTurnWorkedDurations(structuredMessages);
+ structuredMessages.forEach((item, index) => {
+ const turnKey = structuredMessageTurn(item, index);
+ if (isCollapsibleKind(structuredDisplayKind(item, index, turnKey, turnLastIndex))) turnHasActivity.add(turnKey);
+ });
+ for (const [index, item] of structuredMessages.entries()) {
+ if (!updateStructuredMessageArticle(articles[index], item, index, turnLastIndex, turnHasActivity, turnWorkedDurations)) return false;
+ const turnKey = structuredMessageTurn(item, index);
+ const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex);
+ if (isCollapsibleKind(kind)) {
+ indexStructuredActivity(articles[index], item, kind, turnKey, index, structuredMessages);
+ }
+ }
+ reconcileTurnDividers();
+ expandLatestTurnActivity();
+ if (typeof text === "string") output.dataset.outputTail = text;
+ state.structuredMessages = structuredMessages.slice();
+ state.outputSynced = true;
+ if (state.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)
+ && [...output.querySelectorAll(".message.activity")].some((article) => article.dataset.turnId === state.turnId)) {
+ ensureLiveTurnDivider(state.turnId);
+ }
+ if (follow) scrollOutput(output, true);
+ updateScrollToBottom(output);
+ return true;
+ }
+
+ function replaceOutput(text, structuredMessages) {
+ const output = $("output");
+ const preserveBottom = !state.outputSynced || !output || shouldFollowOutput(output);
+ const preservedDistanceFromBottom = output
+ ? Math.max(0, output.scrollHeight - output.scrollTop - output.clientHeight)
+ : 0;
+ const pendingUserText = state.pendingUserText;
+ const snapshotHasPendingUser = Boolean(pendingUserText && (
+ text.includes(`> ${pendingUserText}`)
+ || (Array.isArray(structuredMessages) && structuredMessages.some((item) => {
+ const kind = historyKind(item);
+ return kind === "user" && comparableText(messageText(item)) === comparableText(pendingUserText);
+ }))
+ ));
+ renderEmptyOutput();
+ if (typeof text === "string") output.dataset.outputTail = text;
+ state.structuredMessages = Array.isArray(structuredMessages) ? structuredMessages.slice() : [];
+ if (Array.isArray(structuredMessages) && structuredMessages.length) {
+ // Older follower snapshots may omit the top-level subagents projection.
+ // Derive the small panel model from the same collaboration items used by
+ // the transcript so those agents remain discoverable after reconnect.
+ const suppliedSubagents = state.subagents;
+ const derivedSubagents = deriveSubagentsFromMessages(structuredMessages);
+ if (derivedSubagents.length) {
+ state.subagents = mergeSubagentProjections(derivedSubagents, suppliedSubagents);
+ renderSubagents();
+ } else if (state.subagents.length) {
+ // A complete snapshot with no collaboration items is authoritative for
+ // the transcript. Do not carry a finished agent from the previous
+ // thread into the new composer.
+ state.subagents = [];
+ renderSubagents();
+ }
+ const turnLastIndex = structuredTurnFinalAssistantIndexes(structuredMessages);
+ const turnHasActivity = new Set();
+ const turnWorkedDurations = structuredTurnWorkedDurations(structuredMessages);
+ let previousTurnKey = "";
+ structuredMessages.forEach((item, index) => {
+ const key = structuredMessageTurn(item, index);
+ if (isCollapsibleKind(structuredDisplayKind(item, index, key, turnLastIndex))) turnHasActivity.add(key);
+ });
+ structuredMessages.forEach((item, index) => {
+ const itemText = messageText(item);
+ if (!itemText) return;
+ const sourceKind = historyKind(item);
+ const turnKey = structuredMessageTurn(item, index);
+ const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex);
+ const role = structuredMessageRole(item, kind);
+ const status = structuredMessageStatus(item) || "completed";
+ const durationMs = item.durationMs ?? item.elapsedMs;
+ const duration = elapsedDuration(durationMs);
+ const timestamp = item.startedAtMs ?? item.completedAtMs;
+ if (role === "user" || role === "assistant") {
+ appendDateSeparator(timestamp, {
+ role,
+ turnId: turnKey,
+ turnStart: turnKey !== previousTurnKey,
+ breaksPreviousAdjacency: item.breaksPreviousAdjacency === true,
+ });
+ }
+ const finalAssistant = sourceKind === "assistant" && role === "assistant" && kind === "assistant"
+ && (item.phase === "final_answer" || item.phase === "final-answer" || !isAssistantCommentary(item, index, turnKey, turnLastIndex));
+ const terminalTurn = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"]
+ .includes(String(item.turnStatus || "").replace(/[\s-]+/g, "_").toLowerCase());
+ const workedDuration = turnWorkedDurations.get(turnKey) ?? workedDurationFor(item, null);
+ const activitySummary = historyActivitySummary(item, kind, status, duration) || undefined;
+ const collapsible = isCollapsibleKind(kind);
+ const activityKey = collapsible ? structuredActivityKey(item, kind, turnKey, index) : "";
+ const message = appendMessage(itemText, role, collapsible ? "activity" : "history", "", {
+ kind,
+ status,
+ label: item.label || (kind === "reasoning" ? "思考" : kind === "plan" ? "计划" : kind === "edit" ? "文件变更" : kind === "read" ? "读取文件" : kind === "tool" ? "工具输出" : kind === "subagent" ? "子代理" : kind === "commentary" ? "工作说明" : ""),
+ summary: activitySummary,
+ command: item.command || item.commandLine,
+ turnId: turnKey,
+ structuredKey: structuredMessageKey(item, index),
+ activityKey,
+ itemId: item.itemId,
+ itemType: item.itemType,
+ agentThreadId: kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : "",
+ timestamp,
+ showTimestamp: role === "user" || role === "assistant",
+ showActions: role === "user" || (role === "assistant" && finalAssistant && kind !== "commentary"),
+ collapsible,
+ open: kind === "commentary" || (status === "inProgress" && (kind === "reasoning" || kind === "plan")),
+ });
+ if (message && collapsible) indexStructuredActivity(message.article, item, kind, turnKey, index, structuredMessages);
+ if (finalAssistant && (terminalTurn || workedDuration !== null || (durationMs !== undefined && status !== "inProgress"))) {
+ appendTurnDivider(turnKey, item.turnStatus || status || "completed", workedDuration ?? durationMs, message?.article || null);
+ }
+ previousTurnKey = turnKey;
+ });
+ reconcileTurnDividers();
+ expandLatestTurnActivity();
+ } else if (text) {
+ // The attach adapter prefixes user items with `> ` and separates items
+ // with blank lines. Use that stable marker to recreate the two sides of
+ // the conversation without interpreting arbitrary Markdown as HTML.
+ const chunks = text.split(/\n{2,}/).map((chunk) => chunk.trim()).filter(Boolean);
+ let expectUser = true;
+ for (const chunk of chunks) {
+ const markedUser = chunk.startsWith("> ");
+ if (markedUser && expectUser) {
+ appendMessage(chunk.slice(2), "user", "history", "", { kind: "user" });
+ expectUser = false;
+ } else {
+ appendMessage(chunk, "assistant", "history", "", { kind: "assistant" });
+ expectUser = true;
+ }
+ }
+ }
+ if (pendingUserText && !snapshotHasPendingUser) {
+ appendDateSeparator(Date.now(), { role: "user", force: true });
+ appendMessage(pendingUserText, "user", "streaming", "", { kind: "user", timestamp: Date.now(), showTimestamp: true });
+ }
+ state.pendingUserText = snapshotHasPendingUser ? "" : pendingUserText;
+ state.outputSynced = true;
+ if (state.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)
+ && [...output.querySelectorAll(".message.activity")].some((article) => article.dataset.turnId === state.turnId)) {
+ ensureLiveTurnDivider(state.turnId);
+ }
+ if (preserveBottom) scrollOutput(output, true);
+ else if (output) {
+ // Rebuilding a long history changes scrollHeight. Preserve the user's
+ // distance from the bottom just like the official thread scroll layout,
+ // so a background sync does not yank them away from the message they are
+ // reading.
+ output.scrollTop = Math.max(0, output.scrollHeight - output.clientHeight - preservedDistanceFromBottom);
+ updateScrollToBottom(output);
+ }
+ }
+
+ function applyStructuredMessagesPatch(patch) {
+ if (!isRecord(patch) || !Array.isArray(state.structuredMessages) || !Array.isArray(patch.messages)) return null;
+ const start = Number(patch.start);
+ const deleteCount = Number(patch.deleteCount);
+ if (!Number.isInteger(start) || start < 0 || start > state.structuredMessages.length
+ || !Number.isInteger(deleteCount) || deleteCount < 0
+ || start + deleteCount > state.structuredMessages.length) return null;
+ return [
+ ...state.structuredMessages.slice(0, start),
+ ...patch.messages,
+ ...state.structuredMessages.slice(start + deleteCount),
+ ];
+ }
+
+ function appendOutputChunk(text, stream = "codex", context = {}) {
+ if (!text) return;
+ const output = $("output");
+ const follow = shouldFollowOutput(output);
+ const normalizedStream = String(stream || "codex").toLowerCase();
+ if (normalizedStream === "codex") {
+ const userItem = text.match(/^(?:\n{2,})?> ([^\n]+)\n?$/);
+ if (userItem) {
+ const userText = userItem[1].trim();
+ const eventTurn = context.turnId || state.turnId || "";
+ finishAssistantStream();
+ appendDateSeparator(context.timestamp || Date.now(), { role: "user", turnId: eventTurn, turnStart: true });
+ if (!hasRenderedMessage(userText, eventTurn, "user")) {
+ appendMessage(userText, "user", "history", "", {
+ kind: "user",
+ turnId: eventTurn,
+ timestamp: context.timestamp || Date.now(),
+ showTimestamp: true,
+ });
+ }
+ state.pendingUserText = "";
+ state.outputSynced = true;
+ return;
+ }
+ }
+ if (normalizedStream === "codex" && state.pendingUserText) {
+ const marker = `> ${state.pendingUserText}`;
+ if (text.includes(marker)) {
+ text = text.replace(marker, "").replace(/^\n{1,2}/, "");
+ state.pendingUserText = "";
+ if (!text) return;
+ }
+ }
+
+ const eventTurnId = context.turnId || state.turnId || "";
+ if (state.outputSynced && eventTurnId && normalizedStream === "codex"
+ && hasRenderedCompletedMessage(text, eventTurnId, "assistant")) {
+ // A host replay often sends the same output delta immediately after an
+ // authoritative snapshot. Do not append a second assistant bubble.
+ state.pendingUserText = "";
+ return;
+ }
+
+ const inferredActivity = normalizedStream === "reasoning"
+ ? "thinking"
+ : normalizedStream === "read" || normalizedStream === "reading"
+ ? "reading"
+ : normalizedStream === "stdout" || normalizedStream === "stderr"
+ ? "running"
+ : "generating";
+ if (!state.currentActivity || ["idle", "completed", "failed", "interrupted"].includes(state.currentActivity)) {
+ state.currentActivity = inferredActivity;
+ }
+ if (state.turnStartedAt === null) startTurnClock(context.turnId || state.turnId, null, null);
+ updateLiveActivity(
+ state.currentActivity,
+ state.currentActivityStartedAt || state.turnStartedAt,
+ null,
+ [],
+ context.turnId || state.turnId,
+ );
+ const activityText = statusActivityLabel(state.currentActivity) || t("正在生成");
+ const outputElapsed = elapsedDuration(Math.max(0, Date.now() - (state.turnStartedAt || Date.now())));
+ setConversationStatus(outputElapsed ? `${activityText} · ${outputElapsed}` : activityText, "active");
+
+ // Lifecycle notifications create the canonical row first. Output deltas
+ // from app-server versions that omit itemId are attached to the newest
+ // running row of the corresponding kind.
+ const streamKind = normalizedStream === "reasoning"
+ ? "reasoning"
+ : normalizedStream === "read" || normalizedStream === "reading"
+ ? "read"
+ : normalizedStream === "stdout" || normalizedStream === "stderr"
+ ? "tool"
+ : context.kind || "";
+ const activity = streamKind
+ ? latestRunningActivity(streamKind, context)
+ : null;
+ if (activity) {
+ appendActivityChunk(activity, text);
+ state.activeAssistantBody = activity.body;
+ state.activeAssistantStream = normalizedStream;
+ state.activeAssistantText = activity.outputText;
+ state.activeAssistantActivityKey = activity.key;
+ if (follow) scrollOutput(output, true);
+ state.outputSynced = true;
+ return;
+ }
+ if (state.activeAssistantStream !== normalizedStream) finishAssistantStream();
+ if (!state.activeAssistantBody && normalizedStream === "codex") text = text.replace(/^\n{2,}/, "");
+ if (!state.activeAssistantBody || !output.contains(state.activeAssistantBody)) {
+ const role = normalizedStream === "stderr"
+ ? "error"
+ : normalizedStream === "stdout" || normalizedStream === "read" || normalizedStream === "reading"
+ ? "tool"
+ : normalizedStream === "reasoning"
+ ? "system"
+ : "assistant";
+ const label = normalizedStream === "reasoning" ? "思考"
+ : normalizedStream === "stdout" ? "命令输出"
+ : normalizedStream === "read" || normalizedStream === "reading" ? "读取文件" : "";
+ const kind = normalizedStream === "reasoning" ? "reasoning"
+ : normalizedStream === "stdout" ? "tool"
+ : normalizedStream === "read" || normalizedStream === "reading" || context.kind === "read" ? "read" : "assistant";
+ let message;
+ if (kind === "reasoning" || kind === "tool" || kind === "read") {
+ state.activitySequence += 1;
+ const key = `stream:${state.threadId || "thread"}:${state.turnId || "turn"}:${normalizedStream}:${state.activitySequence}`;
+ const streamActivity = ensureActivity(key, {
+ kind,
+ label: label || (kind === "tool" ? "工具输出" : kind === "read" ? "读取文件" : "思考"),
+ threadId: state.threadId,
+ turnId: eventTurnId || state.turnId,
+ status: "inProgress",
+ anonymous: true,
+ concrete: true,
+ });
+ message = streamActivity && { article: streamActivity.article, content: streamActivity.body };
+ state.activeAssistantActivityKey = streamActivity?.key || null;
+ } else {
+ appendDateSeparator(context.timestamp || Date.now(), { role: "assistant", turnId: eventTurnId, turnStart: false });
+ message = appendMessage("", role, "streaming", "", {
+ kind,
+ label,
+ turnId: eventTurnId,
+ timestamp: context.timestamp || Date.now(),
+ showTimestamp: true,
+ showActions: false,
+ collapsible: false,
+ });
+ }
+ state.activeAssistantBody = message?.content || null;
+ state.activeAssistantStream = normalizedStream;
+ state.activeAssistantText = "";
+ }
+ if (state.activeAssistantActivityKey) {
+ const streamActivity = state.activities.get(state.activeAssistantActivityKey);
+ if (streamActivity) {
+ appendActivityChunk(streamActivity, text);
+ state.activeAssistantText = streamActivity.outputText;
+ if (follow) scrollOutput(output, true);
+ state.outputSynced = true;
+ return;
+ }
+ }
+ if (state.activeAssistantBody) {
+ state.activeAssistantText += text;
+ const article = state.activeAssistantBody.closest(".message");
+ const role = article?.classList.contains("system") ? "system" : article?.classList.contains("error") ? "error" : "assistant";
+ const kind = article?.dataset.kind || "assistant";
+ renderMessageBody(state.activeAssistantBody, state.activeAssistantText, role, "streaming", kind);
+ if (article) article.dataset.rawText = state.activeAssistantText;
+ article?.classList.add("streaming");
+ }
+ if (follow) scrollOutput(output, true);
+ state.outputSynced = true;
+ }
+
+ function finishAssistantStream() {
+ const article = state.activeAssistantBody?.closest(".message");
+ article?.classList.remove("streaming");
+ if (article?.classList.contains("assistant") && !article.querySelector(".message-actions")) addMessageActions(article, true);
+ if (article?.dataset.kind === "reasoning") {
+ const details = article.querySelector("details");
+ if (details) setDetailsExpanded(details, false);
+ }
+ const activity = state.activeAssistantActivityKey
+ ? state.activities.get(state.activeAssistantActivityKey)
+ : null;
+ if (activity?.anonymous && isRunningActivity(activity)) finishActivity(activity, "completed");
+ state.activeAssistantBody = null;
+ state.activeAssistantStream = null;
+ state.activeAssistantText = "";
+ state.activeAssistantActivityKey = null;
+ }
+
+ function eventBelongsToCurrentTurn(payload) {
+ if (!payload || typeof payload !== "object") return true;
+ const threadId = eventThreadId(payload);
+ const turnId = eventTurnId(payload);
+ if (threadId && state.threadId && threadId !== state.threadId) return false;
+ if (turnId && state.turnId && turnId !== state.turnId) return false;
+ if (turnId && state.retiredTurnIds.has(turnId)) return false;
+ return true;
+ }
+
+ function eventMessage(payload) {
+ if (!payload || typeof payload !== "object") return "未知错误";
+ const candidates = [
+ payload.message,
+ payload.error?.message,
+ payload.error,
+ payload.params?.message,
+ payload.params?.error,
+ payload.text,
+ ];
+ const value = candidates.find((entry) => typeof entry === "string" && entry.trim());
+ if (value) return value;
+ try { return JSON.stringify(payload); } catch { return "未知错误"; }
+ }
+
+ function setAttachMode(value) {
+ state.attachMode = Boolean(value);
+ document.body.classList.toggle("attach-mode", state.attachMode);
+ $("sessionMode").textContent = t(state.attachMode
+ ? "已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。"
+ : "当前为独立 app-server 模式。");
+ $("startThreadButton").textContent = t(state.attachMode ? "已附着现有会话" : "启动新 thread");
+ const modeLabel = $("modeLabel");
+ if (modeLabel) modeLabel.textContent = t(state.attachMode ? "本地模式" : "独立模式");
+ const popoverMode = $("popoverMode");
+ if (popoverMode) popoverMode.textContent = t(state.attachMode ? "本地模式" : "独立模式");
+ }
+
+ function firstString(...values) {
+ return values.find((value) => typeof value === "string" && value.trim())?.trim() || "";
+ }
+
+ // Unlike firstString, settings projections need to preserve an explicit
+ // null. Codex uses null for a model with no reasoning selector; treating it
+ // as "missing" leaves the browser showing the previous turn's effort.
+ function firstDefined(...values) {
+ return values.find((value) => value !== undefined);
+ }
+
+ function modelDisplayName(model, effort) {
+ const value = String(model || "").trim();
+ if (!value) return "";
+ const words = value
+ .replace(/^gpt[-_]/i, "")
+ .replace(/[-_]+/g, " ")
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((word) => /^\d+(?:\.\d+)*$/.test(word) ? word : `${word[0].toUpperCase()}${word.slice(1)}`);
+ const effortValue = String(effort || "").trim();
+ if (effortValue && !words.some((word) => word.toLowerCase() === effortValue.toLowerCase())) {
+ words.push(`${effortValue[0].toUpperCase()}${effortValue.slice(1)}`);
+ }
+ return words.join(" ");
+ }
+
+ // Keep this tiny fallback aligned with the model power choices shipped in
+ // the installed official webview. A host that exposes `availableModels`
+ // always wins; these entries only make the picker useful while an older
+ // private IPC build has no model/list projection.
+ const FALLBACK_MODELS = [
+ { model: "gpt-5.6-sol", displayName: "5.6 Sol", description: "通用 Codex 模型", efforts: ["low", "medium", "high", "xhigh"] },
+ { model: "gpt-5.6-terra", displayName: "5.6 Terra", description: "平衡速度与推理", efforts: ["low", "medium", "high", "xhigh"] },
+ ];
+ // Labels used by the shipped Work composer. The compact trigger shows the
+ // model plus its selected reasoning preset (for example `5.6 Sol 标准`).
+ const EFFORT_LABELS = { none: "默认", minimal: "极低", low: "轻度", medium: "标准", high: "深度", xhigh: "极高", max: "最大", ultra: "Ultra" };
+
+ // The compact Work picker is a power control. Keep the mapping deterministic
+ // so keyboard/range input still goes through the same effort protocol used by
+ // the advanced picker.
+ function modelPowerOptions(model) {
+ const efforts = Array.isArray(model?.efforts) ? model.efforts.filter(Boolean) : [];
+ return efforts.map((effort) => ({ effort, label: t(EFFORT_LABELS[effort] || effort) }));
+ }
+
+ function normalizeModelOption(value) {
+ if (typeof value === "string") {
+ const fallback = FALLBACK_MODELS.find((entry) => entry.model === value);
+ return fallback ? { ...fallback } : { model: value, displayName: modelDisplayName(value), description: "可用模型", efforts: [] };
+ }
+ if (!isRecord(value)) return null;
+ const model = firstString(value.model, value.id, value.slug, value.name);
+ if (!model) return null;
+ const hasSupported = Array.isArray(value.supportedReasoningEfforts) || Array.isArray(value.efforts);
+ const supported = Array.isArray(value.supportedReasoningEfforts)
+ ? value.supportedReasoningEfforts.map((entry) => typeof entry === "string" ? entry : firstString(entry?.reasoningEffort, entry?.effort)).filter(Boolean)
+ : Array.isArray(value.efforts) ? value.efforts.filter((entry) => typeof entry === "string") : [];
+ const fallback = FALLBACK_MODELS.find((entry) => entry.model === model);
+ return {
+ model,
+ displayName: firstString(value.displayName, value.label, fallback?.displayName, modelDisplayName(model)),
+ description: firstString(value.description, fallback?.description),
+ // Unknown catalog entries must not inherit a fabricated effort list. The
+ // official advanced picker disables the power controls until the host
+ // reports capabilities for that model.
+ efforts: hasSupported ? supported : fallback?.efforts || [],
+ defaultReasoningEffort: firstString(value.defaultReasoningEffort, value.defaultEffort, fallback?.efforts?.[1]),
+ hidden: value.hidden === true,
+ };
+ }
+
+ function normalizedModelOptions() {
+ let source;
+ if (state.availableModels.length) source = state.availableModels;
+ else if (state.currentModel && FALLBACK_MODELS.some((entry) => entry.model === state.currentModel)) source = FALLBACK_MODELS;
+ else if (state.currentModel) source = [state.currentModel];
+ else return [];
+ const seen = new Set();
+ const result = [];
+ for (const entry of source) {
+ const option = normalizeModelOption(entry);
+ if (!option || option.hidden || seen.has(option.model)) continue;
+ seen.add(option.model);
+ result.push(option);
+ }
+ if (state.currentModel && !seen.has(state.currentModel)) {
+ result.unshift(normalizeModelOption(state.currentModel));
+ }
+ return result;
+ }
+
+ function currentModelOption() {
+ return normalizedModelOptions().find((entry) => entry.model === state.currentModel) || normalizedModelOptions()[0];
+ }
+
+ function renderModelPicker() {
+ const button = $("modelPickerButton");
+ const label = $("modelLabel");
+ const effortLabelNode = $("modelEffortLabel");
+ const menu = $("modelMenu");
+ const modelOptions = $("modelOptions");
+ const effortOptions = $("effortOptions");
+ const powerView = $("modelPowerView");
+ const advancedView = $("modelAdvancedView");
+ const advancedToggle = $("modelAdvancedToggle");
+ const powerSlider = $("modelPowerSlider");
+ const powerValue = $("modelPowerValue");
+ if (!button || !label || !effortLabelNode || !menu || !modelOptions || !effortOptions) return;
+ const current = currentModelOption();
+ if (!current) {
+ button.hidden = true;
+ return;
+ }
+ button.hidden = false;
+ const selectedEffort = state.currentEffort === null
+ ? ""
+ : state.currentEffort || current.defaultReasoningEffort || current.efforts?.[1] || current.efforts?.[0] || "";
+ const effortLabel = selectedEffort ? t(EFFORT_LABELS[selectedEffort] || selectedEffort) : "";
+ label.textContent = current.displayName || modelDisplayName(current.model);
+ effortLabelNode.textContent = effortLabel;
+ effortLabelNode.hidden = !effortLabel;
+ const settingsModelValue = $("settingsModelValue");
+ if (settingsModelValue) settingsModelValue.textContent = [label.textContent, effortLabel].filter(Boolean).join(" ") || t("默认");
+ const triggerLabel = [label.textContent, effortLabel].filter(Boolean).join(" ");
+ button.setAttribute("aria-label", uiLocale() === "en-US"
+ ? `Current model: ${triggerLabel}; change model`
+ : `当前模型 ${triggerLabel},切换模型`);
+ button.setAttribute("title", uiLocale() === "en-US"
+ ? `Change model (current: ${triggerLabel})`
+ : `切换模型(当前 ${triggerLabel})`);
+ modelOptions.replaceChildren();
+ for (const option of normalizedModelOptions()) {
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "model-option";
+ item.setAttribute("role", "option");
+ item.setAttribute("aria-selected", String(option.model === state.currentModel));
+ item.dataset.model = option.model;
+ const name = document.createElement("span");
+ name.className = "model-option-name";
+ name.textContent = option.displayName || option.model;
+ const check = document.createElement("span");
+ check.className = "model-option-check";
+ check.textContent = "✓";
+ const description = document.createElement("span");
+ description.className = "model-option-description";
+ description.textContent = option.description ? t(option.description) : option.model;
+ item.append(name, check, description);
+ item.addEventListener("click", () => selectModel(option.model));
+ modelOptions.append(item);
+ }
+ effortOptions.replaceChildren();
+ const efforts = current.efforts?.length ? [...current.efforts] : [];
+ // Ultra is capability-gated in the official picker. Preserve it when the
+ // host explicitly reports the current setting, but do not advertise it to
+ // every fallback session.
+ if (state.currentEffort === "ultra" && current.model === "gpt-5.6-sol" && !efforts.includes("ultra")) efforts.push("ultra");
+ const selectedMenuEffort = state.currentEffort === null
+ ? ""
+ : state.currentEffort || current.defaultReasoningEffort || efforts[1] || efforts[0] || "";
+ if (!efforts.length) {
+ const empty = document.createElement("div");
+ empty.className = "effort-empty";
+ empty.textContent = t("此模型使用默认推理强度");
+ effortOptions.append(empty);
+ }
+ for (const effort of efforts) {
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "effort-option";
+ item.setAttribute("role", "option");
+ item.setAttribute("aria-selected", String(effort === selectedMenuEffort));
+ item.dataset.effort = effort;
+ const name = document.createElement("span");
+ name.textContent = t(EFFORT_LABELS[effort] || effort);
+ const check = document.createElement("span");
+ check.className = "effort-option-check";
+ check.textContent = "✓";
+ item.append(name, check);
+ item.title = effort;
+ item.addEventListener("click", () => selectEffort(effort));
+ effortOptions.append(item);
+ }
+ const powerOptions = modelPowerOptions(current);
+ if (powerView && advancedView) {
+ powerView.hidden = state.modelAdvancedOpen;
+ advancedView.hidden = !state.modelAdvancedOpen;
+ }
+ if (advancedToggle) {
+ advancedToggle.textContent = t(state.modelAdvancedOpen ? "简洁" : "高级");
+ advancedToggle.setAttribute("aria-label", t(state.modelAdvancedOpen ? "返回简洁模型选择" : "显示高级模型选项"));
+ }
+ if (powerSlider) {
+ const hasPower = powerOptions.length > 0;
+ powerSlider.disabled = !hasPower;
+ powerSlider.min = "0";
+ powerSlider.max = String(Math.max(0, powerOptions.length - 1));
+ const selectedPower = powerOptions.findIndex((entry) => entry.effort === selectedMenuEffort);
+ powerSlider.value = String(selectedPower >= 0 ? selectedPower : Math.min(1, Math.max(0, powerOptions.length - 1)));
+ powerSlider.setAttribute("aria-valuetext", powerOptions[Number(powerSlider.value)]?.label || t("默认"));
+ powerSlider.style.setProperty("--power-position", powerOptions.length > 1
+ ? `${Number(powerSlider.value) / (powerOptions.length - 1) * 100}%`
+ : "0%");
+ if (powerValue) powerValue.textContent = powerOptions[Number(powerSlider.value)]?.label || t("默认");
+ } else if (powerValue) powerValue.textContent = "";
+ $("modelPicker")?.toggleAttribute("data-pending", state.modelUpdatePending);
+ renderControlMode();
+ }
+
+ function setModelMenu(open) {
+ const button = $("modelPickerButton");
+ const menu = $("modelMenu");
+ if (!button || !menu) return;
+ const next = Boolean(open) && threadSettingsAllowed() && !state.modeSwitching && !state.sessionSwitching;
+ menu.hidden = !next;
+ button.setAttribute("aria-expanded", String(next));
+ if (next) {
+ state.modelAdvancedOpen = false;
+ renderModelPicker();
+ }
+ }
+
+ function setPermissionMenu(open) {
+ const button = $("permissionChip");
+ const menu = $("permissionMenu");
+ if (!button || !menu) return;
+ const next = Boolean(open) && threadSettingsAllowed() && !state.modeSwitching && !state.sessionSwitching;
+ menu.hidden = !next;
+ button.setAttribute("aria-expanded", String(next));
+ if (next) renderPermissionMenu();
+ }
+
+ function permissionSandboxLabel(value) {
+ const label = {
+ "read-only": "只读",
+ "workspace-write": "工作区写入",
+ "danger-full-access": "完全访问",
+ }[normalizeSandboxName(value)] || String(value || "工作区写入");
+ return t(label);
+ }
+
+ function renderPermissionMenu() {
+ const menu = $("permissionMenu");
+ const chip = $("permissionChip");
+ const label = $("permissionLabel");
+ if (!menu || !chip || !label) return;
+ const mode = state.sandboxPolicy === "danger-full-access" && state.approvalPolicy === "never"
+ ? "full"
+ : state.sandboxPolicy === "read-only"
+ ? "readonly"
+ : state.approvalPolicy === "untrusted" || state.approvalPolicy === "never" ? "auto" : "ask";
+ label.textContent = t({
+ ask: "需要时询问",
+ auto: "由 Codex 审批",
+ full: "完全访问",
+ readonly: "只读",
+ }[mode]);
+ const settingsPermissionValue = $("settingsPermissionValue");
+ if (settingsPermissionValue) settingsPermissionValue.textContent = label.textContent;
+ chip.setAttribute("aria-label", uiLocale() === "en-US"
+ ? `Change permissions; current: ${label.textContent}`
+ : `修改权限,当前为${label.textContent}`);
+ chip.setAttribute("title", uiLocale() === "en-US"
+ ? `Change permissions (current: ${label.textContent})`
+ : `修改权限(当前:${label.textContent})`);
+ menu.querySelectorAll("[data-permission-mode]").forEach((item) => {
+ item.setAttribute("aria-checked", String(item.dataset.permissionMode === mode));
+ });
+ }
+
+ function selectPermissionMode(mode) {
+ if (!threadSettingsAllowed()) {
+ setConversationStatus("当前模式不支持修改会话设置", "warning");
+ return;
+ }
+ if (mode === "custom") {
+ setPermissionMenu(false);
+ setConversationStatus("自定义权限由 config.toml 管理", "ready");
+ return;
+ }
+ if (mode === "full" && !(state.sandboxPolicy === "danger-full-access" && state.approvalPolicy === "never")) {
+ const confirm = $("permissionConfirm");
+ if (confirm) {
+ confirm.hidden = false;
+ confirm.dataset.pendingMode = mode;
+ setPermissionMenu(false);
+ $("permissionConfirmAccept")?.focus();
+ }
+ return;
+ }
+ applyPermissionMode(mode);
+ }
+
+ function applyPermissionMode(mode) {
+ if (!threadSettingsAllowed()) return;
+ const presets = {
+ ask: { sandboxPolicy: "workspace-write", approvalPolicy: "on-request" },
+ auto: { sandboxPolicy: "workspace-write", approvalPolicy: "untrusted" },
+ full: { sandboxPolicy: "danger-full-access", approvalPolicy: "never" },
+ readonly: { sandboxPolicy: "read-only", approvalPolicy: "on-request" },
+ };
+ const preset = presets[mode];
+ if (!preset) return;
+ state.sandboxPolicy = preset.sandboxPolicy;
+ state.approvalPolicy = preset.approvalPolicy;
+ const sandboxInput = $("sandboxInput");
+ const approvalInput = $("approvalInput");
+ if (sandboxInput && [...sandboxInput.options].some((option) => option.value === state.sandboxPolicy)) sandboxInput.value = state.sandboxPolicy;
+ if (approvalInput && [...approvalInput.options].some((option) => option.value === state.approvalPolicy)) approvalInput.value = state.approvalPolicy;
+ renderPermissionMenu();
+ updateThreadSettings(undefined, undefined, {
+ sandboxPolicy: state.sandboxPolicy,
+ approvalPolicy: state.approvalPolicy,
+ permissions: state.sandboxPolicy === "read-only"
+ ? ":read-only"
+ : state.sandboxPolicy === "danger-full-access" ? ":danger-full-access" : ":workspace",
+ approvalsReviewer: "user",
+ });
+ setPermissionMenu(false);
+ }
+
+ function selectPermissionSetting(kind, value) {
+ if (!threadSettingsAllowed()) return;
+ if (!value) return;
+ if (kind === "sandbox") {
+ state.sandboxPolicy = normalizeSandboxName(value);
+ const input = $("sandboxInput");
+ if (input && [...input.options].some((option) => option.value === state.sandboxPolicy)) input.value = state.sandboxPolicy;
+ } else {
+ state.approvalPolicy = String(value);
+ const input = $("approvalInput");
+ if (input && [...input.options].some((option) => option.value === state.approvalPolicy)) input.value = state.approvalPolicy;
+ }
+ renderPermissionMenu();
+ updateThreadSettings(undefined, undefined, {
+ sandboxPolicy: state.sandboxPolicy,
+ approvalPolicy: state.approvalPolicy,
+ permissions: state.sandboxPolicy === "read-only"
+ ? ":read-only"
+ : state.sandboxPolicy === "danger-full-access" ? ":danger-full-access" : ":workspace",
+ approvalsReviewer: "user",
+ });
+ setPermissionMenu(false);
+ }
+
+ function normalizeUsage(value) {
+ if (!isRecord(value)) return null;
+ const source = isRecord(value.contextWindow) ? value.contextWindow : isRecord(value.context) ? value.context : value;
+ const total = isRecord(value.total) ? value.total : isRecord(source.total) ? source.total : {};
+ const last = isRecord(value.last) ? value.last : isRecord(source.last) ? source.last : {};
+ const breakdownTotal = (entry) => {
+ if (!isRecord(entry)) return null;
+ const explicit = finiteNumber(entry.totalTokens, entry.total_tokens, entry.tokens, entry.used, entry.inputTokens);
+ if (explicit !== null) return explicit;
+ const parts = [
+ finiteNumber(entry.inputTokens, entry.input_tokens),
+ finiteNumber(entry.cachedInputTokens, entry.cached_input_tokens),
+ finiteNumber(entry.cacheWriteInputTokens, entry.cache_write_input_tokens),
+ finiteNumber(entry.outputTokens, entry.output_tokens),
+ finiteNumber(entry.reasoningOutputTokens, entry.reasoning_output_tokens),
+ ].filter((entry) => entry !== null);
+ return parts.length ? parts.reduce((sum, entry) => sum + entry, 0) : null;
+ };
+ const used = finiteNumber(
+ source.used,
+ source.usedTokens,
+ source.inputTokens,
+ source.input_tokens,
+ source.tokensUsed,
+ source.totalTokens,
+ value.usedTokens,
+ value.inputTokens,
+ breakdownTotal(last),
+ breakdownTotal(total),
+ );
+ const limit = finiteNumber(
+ value.modelContextWindow,
+ value.model_context_window,
+ source.limit,
+ source.max,
+ source.maxTokens,
+ typeof source.contextWindow === "number" ? source.contextWindow : null,
+ value.limit,
+ value.maxTokens,
+ );
+ const remaining = finiteNumber(source.remaining, source.remainingTokens, value.remainingTokens);
+ const percent = finiteNumber(source.percent, source.percentage, value.percent, value.percentage);
+ if (used === null && limit === null && remaining === null && percent === null) return null;
+ const computedPercent = percent !== null
+ ? Math.max(0, Math.min(100, percent))
+ : used !== null && limit !== null && limit > 0 ? Math.max(0, Math.min(100, used / limit * 100))
+ : used !== null && remaining !== null && used + remaining > 0 ? used / (used + remaining) * 100 : 0;
+ const clampedUsed = used !== null && limit !== null && limit > 0 ? Math.min(used, limit) : used;
+ const computedRemaining = remaining !== null
+ ? remaining
+ : clampedUsed !== null && limit !== null ? Math.max(limit - clampedUsed, 0) : null;
+ return {
+ used: clampedUsed,
+ limit,
+ remaining: computedRemaining,
+ percent: computedPercent,
+ totalTokens: breakdownTotal(total),
+ lastTokens: breakdownTotal(last),
+ };
+ }
+
+ function renderUsage() {
+ const picker = $("usagePicker");
+ const button = $("usageButton");
+ const label = $("usageLabel");
+ const ring = $("usageRing");
+ const summary = $("usageSummary");
+ const details = $("usageDetails");
+ const bar = $("usageMeterBar");
+ if (!picker || !button || !label || !ring || !summary || !details || !bar) return;
+ const usage = normalizeUsage(state.tokenUsage);
+ picker.hidden = !usage;
+ if (!usage) return;
+ const percent = Math.round(usage.percent);
+ label.textContent = `${percent}%`;
+ ring.style.setProperty("--usage-percent", `${percent}%`);
+ ring.dataset.level = percent >= 90 ? "critical" : percent >= 70 ? "warning" : "normal";
+ const remainingPercent = Math.max(0, 100 - percent);
+ button.title = uiLocale() === "en-US"
+ ? `Context used ${percent}% (${remainingPercent}% remaining)`
+ : `上下文已使用 ${percent}%(剩余 ${remainingPercent}%)`;
+ button.setAttribute("aria-label", button.title);
+ summary.textContent = usage.limit !== null
+ ? `${usage.used ?? 0} / ${usage.limit} tokens (${percent}%)`
+ : uiLocale() === "en-US" ? `${percent}% used` : `${percent}% 已使用`;
+ bar.style.width = `${percent}%`;
+ details.textContent = [
+ usage.remaining !== null ? (uiLocale() === "en-US" ? `${usage.remaining} tokens remaining` : `剩余 ${usage.remaining} tokens`) : "",
+ usage.used !== null ? (uiLocale() === "en-US" ? `Current context: ${usage.used} tokens` : `当前上下文 ${usage.used} tokens`) : "",
+ usage.lastTokens !== null && usage.lastTokens !== usage.used ? (uiLocale() === "en-US" ? `Latest request: ${usage.lastTokens} tokens` : `最近请求 ${usage.lastTokens} tokens`) : "",
+ usage.totalTokens !== null && usage.totalTokens !== usage.used ? (uiLocale() === "en-US" ? `Total: ${usage.totalTokens} tokens` : `累计 ${usage.totalTokens} tokens`) : "",
+ ].filter(Boolean).join("\n");
+ }
+
+ function setUsageMenu(open) {
+ const button = $("usageButton");
+ const menu = $("usageMenu");
+ if (!button || !menu) return;
+ const next = Boolean(open);
+ menu.hidden = !next;
+ button.setAttribute("aria-expanded", String(next));
+ }
+
+ function updateThreadSettings(model, effort, extra = {}) {
+ if (!threadSettingsAllowed() || !state.threadId || state.role !== "operator") return;
+ const threadSettings = {};
+ if (model) threadSettings.model = model;
+ if (effort !== undefined) threadSettings.effort = effort;
+ if (isRecord(extra)) {
+ for (const [key, value] of Object.entries(extra)) {
+ if (value !== undefined) threadSettings[key] = value;
+ }
+ }
+ if (!Object.keys(threadSettings).length) return;
+ state.modelUpdatePending = true;
+ if (model) state.currentModel = model;
+ if (effort !== undefined) state.currentEffort = effort === null ? null : effort || "";
+ renderModelPicker();
+ try {
+ command("thread/settings/update", { threadId: state.threadId, threadSettings });
+ } catch (error) {
+ state.modelUpdatePending = false;
+ appendOutput(error.message || "无法更新模型设置", "error");
+ renderModelPicker();
+ }
+ }
+
+ function currentEffortParams() {
+ if (state.currentEffort === null) return { effort: null };
+ return state.currentEffort ? { effort: state.currentEffort } : {};
+ }
+
+ function selectModel(model) {
+ const option = normalizedModelOptions().find((entry) => entry.model === model);
+ const effort = option?.efforts?.length
+ ? option.efforts.includes(state.currentEffort)
+ ? state.currentEffort
+ : option.defaultReasoningEffort || option.efforts[1] || option.efforts[0]
+ : null;
+ updateThreadSettings(model, effort);
+ setModelMenu(false);
+ }
+
+ function selectEffort(effort) {
+ updateThreadSettings(undefined, effort);
+ setModelMenu(false);
+ }
+
+ function selectPowerIndex(value) {
+ const current = currentModelOption();
+ const options = modelPowerOptions(current);
+ if (!options.length) return;
+ const index = Math.max(0, Math.min(options.length - 1, Number(value) || 0));
+ const option = options[index];
+ if (!option) return;
+ updateThreadSettings(undefined, option.effort);
+ // The official power control remains open while keyboard arrows adjust it.
+ state.modelAdvancedOpen = false;
+ renderModelPicker();
+ }
+
+ function normalizeSubagentStatus(value) {
+ const raw = isRecord(value) ? firstString(value.status, value.type, value.state) : value;
+ const normalized = String(raw || "").replace(/[\s_-]+/g, "").toLowerCase();
+ if (["running", "working", "interacted", "updated", "inprogress", "active", "started"].includes(normalized)) return "working";
+ if (["pending", "pendinginit", "waiting", "queued", "waitingforinput", "awaitinginstruction", "waitingforinstruction", "needsinput"].includes(normalized)) return "waiting";
+ if (["failed", "errored", "error", "notfound"].includes(normalized)) return "failed";
+ if (["completed", "complete", "done", "interrupted", "shutdown", "cancelled", "canceled"].includes(normalized)) return "done";
+ return normalized || "waiting";
+ }
+
+ function subagentStatusLabel(status) {
+ // These are the localized equivalents of the official background-agent
+ // rows ("is working", "is awaiting instruction", "is done").
+ return t({ waiting: "正在等待指示", working: "正在工作", done: "已完成", failed: "失败" }[status] || status);
+ }
+
+ function subagentThreadId(entry) {
+ return firstString(entry.threadId, entry.agentThreadId, entry.childThreadId);
+ }
+
+ function subagentElapsed(entry) {
+ const startedAt = timestampMs(entry.startedAtMs, entry.startedAt, entry.createdAtMs, entry.createdAt);
+ if (startedAt === null) return "";
+ const status = normalizeSubagentStatus(entry.status);
+ const completedAt = timestampMs(entry.completedAtMs, entry.completedAt, entry.finishedAtMs, entry.finishedAt, entry.lastAssistantMessageAtMs);
+ const end = status === "working" || status === "waiting" ? Date.now() : completedAt;
+ if (end === null || end === undefined) return "";
+ return elapsedDuration(Math.max(0, end - startedAt));
+ }
+
+ function focusSubagentActivity(threadId) {
+ if (!threadId) return;
+ const output = $("output");
+ const target = output
+ ? [...output.querySelectorAll(".message[data-agent-thread-id]")]
+ .reverse()
+ .find((article) => article.dataset.agentThreadId === threadId)
+ : null;
+ if (!target) return;
+ const turnId = target.dataset.turnId;
+ const turnActivity = turnId ? state.turnDividers.get(turnId) : null;
+ const turnToggle = turnActivity?.querySelector(".turn-divider-toggle");
+ if (turnToggle?.getAttribute("aria-expanded") === "false") {
+ turnToggle.setAttribute("aria-expanded", "true");
+ setTurnActivityVisibility(turnId, true);
+ }
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
+ target.classList.remove("subagent-focus");
+ void target.offsetWidth;
+ target.classList.add("subagent-focus");
+ setTimeout(() => target.classList.remove("subagent-focus"), 1_200);
+ }
+
+ function createSubagentPanelRow(entry) {
+ const status = normalizeSubagentStatus(entry.status);
+ const threadId = subagentThreadId(entry);
+ const row = document.createElement(threadId ? "button" : "div");
+ if (threadId) row.type = "button";
+ row.className = "subagent-row";
+ row.dataset.status = status;
+ if (threadId) row.dataset.agentThreadId = threadId;
+ const startedAt = timestampMs(entry.startedAtMs, entry.startedAt, entry.createdAtMs, entry.createdAt);
+ const completedAt = timestampMs(
+ entry.completedAtMs,
+ entry.completedAt,
+ entry.finishedAtMs,
+ entry.finishedAt,
+ entry.lastAssistantMessageAtMs,
+ entry.recencyAtMs,
+ entry.recencyAt,
+ );
+ if (startedAt !== null) row.dataset.startedAt = String(startedAt);
+ if (completedAt !== null) row.dataset.completedAt = String(completedAt);
+
+ const icon = document.createElement("span");
+ icon.className = "subagent-icon";
+ icon.setAttribute("aria-hidden", "true");
+ icon.append(createSubagentIcon());
+ const copy = document.createElement("span");
+ copy.className = "subagent-copy";
+ const name = document.createElement("span");
+ name.className = "subagent-name";
+ name.textContent = firstString(entry.displayName, entry.agentNickname, entry.name, entry.agentPath) || t("子代理");
+ copy.append(name);
+ const stateLabel = document.createElement("span");
+ stateLabel.className = "subagent-status";
+ const statusText = document.createElement("span");
+ statusText.className = "subagent-status-text";
+ statusText.textContent = subagentStatusLabel(status);
+ stateLabel.append(statusText);
+ const diff = isRecord(entry.diffStats) ? entry.diffStats : isRecord(entry.diff_stats) ? entry.diff_stats : null;
+ const added = finiteNumber(diff?.linesAdded, diff?.added);
+ const removed = finiteNumber(diff?.linesRemoved, diff?.removed);
+ if (added !== null || removed !== null) {
+ const diffLabel = document.createElement("span");
+ diffLabel.className = "subagent-diff-stats";
+ diffLabel.textContent = `+${Math.max(0, added || 0)} -${Math.max(0, removed || 0)}`;
+ stateLabel.append(diffLabel);
+ }
+ const elapsed = document.createElement("span");
+ elapsed.className = "subagent-elapsed";
+ elapsed.textContent = subagentElapsed(entry);
+ // The composer keeps the row compact, but exposes timing in the tooltip
+ // and makes the elapsed node available for live updates.
+ if (elapsed.textContent) stateLabel.append(elapsed);
+ row.append(icon, copy, stateLabel);
+ const objective = firstString(entry.statusMessage, entry.objective, entry.prompt, entry.role, entry.agentRole);
+ const model = firstString(entry.spawnModel, entry.model, entry.modelId);
+ const tooltipParts = [objective, model ? (uiLocale() === "en-US" ? `Using ${model}` : `使用 ${model}`) : "", elapsed.textContent ? (uiLocale() === "en-US" ? `Elapsed: ${elapsed.textContent}` : `已用时 ${elapsed.textContent}`) : ""].filter(Boolean);
+ if (tooltipParts.length) row.title = `${name.textContent}: ${tooltipParts.join(" · ")}`;
+ if (threadId) {
+ row.title = tooltipParts.length
+ ? `${name.textContent}: ${tooltipParts.join(" · ")}`
+ : uiLocale() === "en-US" ? `View ${name.textContent}'s activity` : `查看 ${name.textContent} 的活动`;
+ row.setAttribute("aria-label", `${name.textContent}, ${subagentStatusLabel(status)}`);
+ row.addEventListener("click", () => focusSubagentActivity(threadId));
+ }
+ return row;
+ }
+
+ function deriveSubagentsFromMessages(messages) {
+ if (!Array.isArray(messages)) return [];
+ deriveStructuredTurnKeys(messages);
+ const entries = new Map();
+ let anonymousIndex = 0;
+ const upsert = (item, threadId = "", itemIndex = -1) => {
+ const agent = isRecord(item.agent) ? item.agent : {};
+ const displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, agent.displayName, agent.name);
+ const key = threadId || displayName || firstString(item.agentPath, item.action, item.activityKind) || `anonymous-${anonymousIndex++}`;
+ const existing = entries.get(key) || { threadId: threadId || "", displayName: null, prompt: null, objective: null, status: "working", statusMessage: null, canInteract: false };
+ const activityKind = firstString(item.activityKind, item.kind, agent.activityKind);
+ const rawStatus = firstString(item.displayStatus, item.status, item.state, agent.status)
+ || (activityKind === "completed" ? "completed" : activityKind === "interrupted" ? "interrupted" : "working");
+ const normalizedStatus = normalizeSubagentStatus(rawStatus);
+ existing.threadId = existing.threadId || threadId;
+ existing.displayName = displayName || existing.displayName;
+ existing.agentPath = firstString(item.agentPath, agent.agentPath, existing.agentPath);
+ existing.prompt = firstString(item.prompt, agent.prompt, existing.prompt) || null;
+ existing.objective = firstString(item.objective, item.statusMessage, item.prompt, agent.objective, existing.objective) || null;
+ existing.statusMessage = firstString(item.statusMessage, agent.statusMessage, existing.statusMessage) || null;
+ existing.status = normalizedStatus;
+ existing.canInteract = item.canInteract !== undefined ? item.canInteract !== false : existing.canInteract;
+ const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs, agent.startedAtMs);
+ const completedAt = timestampMs(
+ item.completedAtMs,
+ item.completedAt,
+ item.finishedAtMs,
+ item.finishedAt,
+ item.lastAssistantMessageAtMs,
+ item.recencyAtMs,
+ item.recencyAt,
+ agent.completedAtMs,
+ agent.lastAssistantMessageAtMs,
+ agent.recencyAtMs,
+ );
+ if (startedAt !== null) existing.startedAtMs = existing.startedAtMs ?? startedAt;
+ if (completedAt !== null) existing.completedAtMs = completedAt;
+ if (existing.completedAtMs === undefined && normalizedStatus === "done" && itemIndex >= 0) {
+ const turnKey = structuredMessageTurn(item, itemIndex);
+ for (let cursor = itemIndex + 1; cursor < messages.length; cursor += 1) {
+ const next = messages[cursor];
+ if (!isRecord(next)) continue;
+ if (structuredMessageTurn(next, cursor) !== turnKey) break;
+ const nextStartedAt = timestampMs(next.startedAtMs, next.startedAt, next.createdAtMs);
+ if (nextStartedAt !== null) {
+ existing.completedAtMs = nextStartedAt;
+ break;
+ }
+ }
+ }
+ if (item.model !== undefined || agent.model !== undefined) existing.model = firstString(item.model, agent.model, existing.model) || null;
+ entries.set(key, existing);
+ };
+ messages.forEach((item, index) => {
+ if (!isRecord(item) || historyKind(item) !== "subagent") return;
+ const receivers = Array.isArray(item.receiverThreadIds)
+ ? item.receiverThreadIds.map(String)
+ : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : [];
+ const states = isRecord(item.agentsStates) ? Object.keys(item.agentsStates) : [];
+ const ids = [...new Set([
+ firstString(item.agentThreadId, item.childThreadId),
+ ...receivers,
+ ...states,
+ ].filter(Boolean))];
+ if (!ids.length) upsert(item, "", index);
+ else ids.forEach((id) => upsert(item, id, index));
+ });
+ return [...entries.values()];
+ }
+
+ function subagentIdentity(entry) {
+ if (!isRecord(entry)) return "";
+ return firstString(
+ entry.threadId,
+ entry.agentThreadId,
+ entry.childThreadId,
+ entry.displayName,
+ entry.agentNickname,
+ entry.name,
+ entry.agentPath,
+ );
+ }
+
+ // A snapshot can carry a rich top-level subagent projection while the
+ // message list only contains the compact activity item. Merge matching
+ // identities so timestamps, model names, and interaction flags survive the
+ // transcript re-render without carrying stale agents across threads.
+ function mergeSubagentProjections(derived, supplied) {
+ const rich = Array.isArray(supplied) ? supplied.filter(isRecord) : [];
+ if (!rich.length) return derived;
+ const byIdentity = new Map(rich
+ .map((entry) => [subagentIdentity(entry), entry])
+ .filter(([identity]) => Boolean(identity)));
+ const matched = new Set();
+ const merged = derived.map((entry) => {
+ const identity = subagentIdentity(entry);
+ const source = identity ? byIdentity.get(identity) : undefined;
+ if (!source) return entry;
+ matched.add(source);
+ return { ...entry, ...source };
+ });
+ // Keep a rich top-level entry when the transcript projection is absent
+ // (for example while a newly spawned agent has not emitted its first
+ // activity item yet), but discard unrelated entries from an older thread.
+ if (!merged.length) return rich;
+ for (const source of rich) {
+ if (matched.has(source)) continue;
+ const identity = subagentIdentity(source);
+ if (identity && !merged.some((entry) => subagentIdentity(entry) === identity)
+ && normalizeSubagentStatus(source.status) !== "done") merged.push(source);
+ }
+ return merged;
+ }
+
+ function refreshSubagentElapsed() {
+ for (const row of document.querySelectorAll(".subagent-row[data-started-at]")) {
+ const startedAt = timestampMs(row.dataset.startedAt);
+ if (startedAt === null) continue;
+ const completedAt = timestampMs(row.dataset.completedAt);
+ const status = normalizeSubagentStatus(row.dataset.status);
+ const end = status === "working" || status === "waiting" ? Date.now() : completedAt;
+ const label = row.querySelector(".subagent-elapsed");
+ if (label) label.textContent = end === null || end === undefined ? "" : elapsedDuration(Math.max(0, end - startedAt));
+ }
+ }
+
+ function renderSubagents() {
+ const panel = $("subagentsPanel");
+ const list = $("subagentsList");
+ const count = $("subagentsCount");
+ if (!panel || !list || !count) return;
+ const entries = Array.isArray(state.subagents) ? state.subagents.filter(isRecord) : [];
+ const visibleEntries = entries.filter((entry) => firstString(
+ entry.displayName,
+ entry.agentNickname,
+ entry.name,
+ entry.agentPath,
+ entry.threadId,
+ entry.agentThreadId,
+ entry.childThreadId,
+ entry.objective,
+ ));
+ panel.hidden = visibleEntries.length === 0;
+ if (!visibleEntries.length) {
+ count.textContent = "";
+ if (isRecord(state.subagentsExpanded)) {
+ state.subagentsExpanded.active = false;
+ state.subagentsExpanded.done = false;
+ }
+ list.replaceChildren();
+ return;
+ }
+ const normalizedEntries = visibleEntries.map((entry) => ({ entry, status: normalizeSubagentStatus(entry.status) }));
+ // The official composer uses one compact disclosure row. It does not
+ // split the list into active/done sections or show per-agent wall-clock
+ // durations; status is rendered inline beside each display name.
+ const title = $("subagentsToggle")?.querySelector(".subagents-title");
+ if (title) title.textContent = uiLocale() === "en-US"
+ ? `${normalizedEntries.length} background agents${state.subagentsCollapsed ? "" : " · @ to mention agents"}`
+ : `${normalizedEntries.length} 个后台代理${state.subagentsCollapsed ? "" : " · @ 可标记代理"}`;
+ count.textContent = "";
+ panel.dataset.collapsed = String(state.subagentsCollapsed);
+ const toggle = $("subagentsToggle");
+ if (toggle) toggle.setAttribute("aria-expanded", String(!state.subagentsCollapsed));
+ list.setAttribute("aria-hidden", String(state.subagentsCollapsed));
+ list.inert = state.subagentsCollapsed;
+ list.replaceChildren();
+ const rows = document.createElement("div");
+ rows.className = "subagent-section-rows";
+ for (const { entry } of normalizedEntries) rows.append(createSubagentPanelRow(entry));
+ list.append(rows);
+ }
+
+ function normalizeSandboxName(value) {
+ const normalized = String(value || "")
+ .replace(/^:+/, "")
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+ .replace(/[\s_]+/g, "-")
+ .toLowerCase();
+ if (normalized === "dangerfullaccess" || normalized === "full-access" || normalized === "fullaccess") return "danger-full-access";
+ if (normalized === "workspacewrite") return "workspace-write";
+ if (normalized === "workspace" || normalized === "write") return "workspace-write";
+ if (normalized === "readonly" || normalized === "read") return "read-only";
+ return normalized;
+ }
+
+ function sandboxFromPermissions(value) {
+ if (typeof value === "string") {
+ const normalized = normalizeSandboxName(value);
+ if (["read-only", "workspace-write", "danger-full-access"].includes(normalized)) return normalized;
+ return "";
+ }
+ if (!isRecord(value)) return "";
+ const explicit = firstString(value.sandboxPolicy, value.sandbox, value.mode, value.profile, value.type);
+ if (explicit) {
+ const normalized = normalizeSandboxName(explicit);
+ if (["read-only", "workspace-write", "danger-full-access"].includes(normalized)) return normalized;
+ }
+ if (value.dangerFullAccess === true || value.fullAccess === true || value.full_access === true) return "danger-full-access";
+ const fileSystem = isRecord(value.fileSystem) ? value.fileSystem : isRecord(value.file_system) ? value.file_system : value;
+ if (fileSystem.write === true || fileSystem.workspaceWrite === true || fileSystem.workspace_write === true) return "workspace-write";
+ if (fileSystem.readOnly === true || fileSystem.read_only === true || fileSystem.read === true) return "read-only";
+ return "";
+ }
+
+ function normalizeApprovalPolicy(value) {
+ const candidate = isRecord(value)
+ ? firstString(value.policy, value.mode, value.type, value.approvalPolicy, value.approval_policy)
+ : firstString(value);
+ const normalized = String(candidate || "").replace(/[\s_]+/g, "-").toLowerCase();
+ return ["on-request", "never", "untrusted"].includes(normalized) ? normalized : "";
+ }
+
+ // Model metadata belongs to the attached thread. Clear it before applying
+ // a snapshot for a different thread so an older catalog cannot leak into a
+ // newly selected conversation that does not expose model data.
+ function resetSessionModelMetadata() {
+ state.currentModel = "";
+ state.currentEffort = "";
+ state.availableModels = [];
+ state.modelUpdatePending = false;
+ state.tokenUsage = null;
+ state.workedDurationMs = null;
+ state.lastWorkedDurationMs = null;
+ state.turnWorkStartedAt = null;
+ state.finalAssistantStartedAt = null;
+ state.sandboxPolicy = "workspace-write";
+ state.approvalPolicy = "on-request";
+ const modelInput = $("modelInput");
+ if (modelInput) modelInput.value = "";
+ const label = $("modelLabel");
+ if (label) {
+ label.textContent = "";
+ label.hidden = true;
+ }
+ setModelMenu(false);
+ renderModelPicker();
+ renderPermissionMenu();
+ renderUsage();
+ }
+
+ function prepareForSessionSnapshot(threadId) {
+ const incoming = typeof threadId === "string" ? threadId : "";
+ const previous = state.syncedThreadId !== null ? state.syncedThreadId : state.threadId || "";
+ if (previous !== incoming && (previous || incoming)) {
+ resetSessionModelMetadata();
+ state.turnExpansion.clear();
+ }
+ if (incoming) syncSessionActive(incoming);
+ return previous !== incoming;
+ }
+
+ function snapshotHistoryComplete(...sources) {
+ const seen = new Set();
+ const visit = (value, depth = 0) => {
+ if (!isRecord(value) || seen.has(value) || depth > 3) return undefined;
+ seen.add(value);
+ if (typeof value.historyComplete === "boolean") return value.historyComplete;
+ for (const key of ["metadata", "sessionMetadata", "state", "snapshot"]) {
+ const nested = visit(value[key], depth + 1);
+ if (typeof nested === "boolean") return nested;
+ }
+ return undefined;
+ };
+ for (const source of sources) {
+ const result = visit(source);
+ if (typeof result === "boolean") return result;
+ }
+ return undefined;
+ }
+
+ function projectionTargetThreadId() {
+ const switchingTarget = firstString(state.sessionSwitchContext?.targetThreadId);
+ if (switchingTarget) return switchingTarget;
+ if (state.sessionSelectedThreadId && state.sessionSelectedThreadId !== state.syncedThreadId) {
+ return state.sessionSelectedThreadId;
+ }
+ return firstString(state.threadId, state.syncedThreadId);
+ }
+
+ function outputProjectionAllowed(threadId) {
+ const incoming = String(threadId || "");
+ const expected = projectionTargetThreadId();
+ return !expected || !incoming || incoming === expected;
+ }
+
+ function hasVisibleOutputProjection() {
+ const output = $("output");
+ return Boolean(
+ state.structuredMessages.length
+ || output?.dataset.outputTail
+ || output?.querySelector(".message")
+ );
+ }
+
+ function finishSessionSnapshotCommit(threadId, authoritativeSnapshot = false) {
+ const incoming = String(threadId || "");
+ if (!incoming) return;
+ const context = state.sessionSwitchContext;
+ if (authoritativeSnapshot && context?.targetThreadId === incoming) {
+ context.targetSnapshotReady = true;
+ finishSessionSwitchIfReady();
+ }
+ syncSessionActive(incoming);
+ }
+
+ // Transcript, active-thread identity, and switch completion are one commit.
+ // This prevents a metadata-only/placeholder reconnect snapshot from clearing
+ // a fully rendered thread or completing a switch before its history arrives.
+ function commitOutputProjection(threadId, text, structuredMessages, options = {}) {
+ const incoming = String(threadId || "");
+ if (!outputProjectionAllowed(incoming)) return false;
+ const hasContent = Boolean(
+ (typeof text === "string" && text.length)
+ || (Array.isArray(structuredMessages) && structuredMessages.length)
+ );
+ const historyComplete = options.historyComplete;
+ if (!hasContent && historyComplete !== true) {
+ // All relay control snapshots have projection-shaped placeholder fields.
+ // Until the host explicitly says history loading is complete, an empty
+ // projection is not authoritative—even when the current DOM is empty.
+ return false;
+ }
+ const changedThread = state.syncedThreadId !== null && state.syncedThreadId !== incoming;
+ prepareForSessionSnapshot(incoming);
+ if (changedThread) {
+ state.outputSynced = false;
+ state.snapshotNoticeShown = false;
+ }
+ replaceOutput(typeof text === "string" ? text : "", structuredMessages);
+ state.syncedThreadId = incoming;
+ if (incoming) state.threadId = incoming;
+ finishSessionSnapshotCommit(incoming, options.authoritativeSnapshot === true);
+ return true;
+ }
+
+ function applySessionMetadata(metadata, snapshotState = {}) {
+ const meta = isRecord(metadata) ? metadata : {};
+ const stateSnapshot = isRecord(snapshotState) ? snapshotState : {};
+ const thread = isRecord(meta.thread) ? meta.thread : isRecord(stateSnapshot.thread) ? stateSnapshot.thread : {};
+ const settings = isRecord(meta.threadSettings)
+ ? meta.threadSettings
+ : isRecord(meta.latestThreadSettings)
+ ? meta.latestThreadSettings
+ : isRecord(meta.settings)
+ ? meta.settings
+ : isRecord(stateSnapshot.threadSettings) ? stateSnapshot.threadSettings : {};
+ const title = firstString(meta.title, meta.threadTitle, meta.name, thread.title, thread.name, thread.preview, stateSnapshot.title, stateSnapshot.threadTitle);
+ if (title) $("threadTitle").textContent = title;
+
+ const cwd = firstString(meta.cwd, settings.cwd, stateSnapshot.cwd);
+ if (cwd) $("cwdInput").value = cwd;
+ const modelValue = meta.latestModel ?? meta.model ?? meta.modelName ?? meta.modelId
+ ?? settings.model ?? settings.modelName ?? stateSnapshot.latestModel ?? stateSnapshot.model ?? stateSnapshot.modelName;
+ const model = typeof modelValue === "object" && modelValue !== null
+ ? firstString(modelValue.name, modelValue.id, modelValue.slug)
+ : firstString(modelValue);
+ if (model) {
+ $("modelInput").value = model;
+ const modelLabel = $("modelLabel");
+ const effortValue = firstDefined(
+ meta.latestReasoningEffort,
+ meta.effort,
+ settings.effort,
+ stateSnapshot.latestReasoningEffort,
+ stateSnapshot.effort,
+ );
+ state.currentModel = model;
+ if (effortValue !== undefined) {
+ state.currentEffort = effortValue === null ? null : firstString(effortValue);
+ }
+ if (modelLabel) { modelLabel.textContent = modelDisplayName(model); modelLabel.hidden = false; }
+ } else if (modelValue !== undefined) {
+ const modelLabel = $("modelLabel");
+ if (modelLabel) modelLabel.hidden = true;
+ }
+ const modelsValue = meta.availableModels ?? meta.models ?? stateSnapshot.availableModels ?? stateSnapshot.models;
+ if (Array.isArray(modelsValue)) state.availableModels = modelsValue.filter((entry) => typeof entry === "string" || isRecord(entry));
+ const subagentsValue = meta.subagents ?? stateSnapshot.subagents;
+ if (Array.isArray(subagentsValue)) state.subagents = subagentsValue;
+ const usageValue = meta.tokenUsage ?? meta.latestTokenUsageInfo ?? meta.contextUsage ?? meta.usage
+ ?? stateSnapshot.tokenUsage ?? stateSnapshot.latestTokenUsageInfo ?? stateSnapshot.contextUsage ?? stateSnapshot.usage;
+ if (usageValue !== undefined) state.tokenUsage = usageValue;
+ const metadataWorkedDuration = finiteNumber(
+ meta.workedDurationMs,
+ meta.workDurationMs,
+ meta.workedForMs,
+ meta.workedFor?.durationMs,
+ settings.workedDurationMs,
+ stateSnapshot.workedDurationMs,
+ stateSnapshot.workDurationMs,
+ );
+ if (metadataWorkedDuration !== null) {
+ state.workedDurationMs = Math.max(0, metadataWorkedDuration);
+ state.lastWorkedDurationMs = Math.max(0, metadataWorkedDuration);
+ }
+ const metadataWorkStart = timestampMs(
+ meta.firstTurnWorkItemStartedAtMs,
+ meta.firstWorkItemStartedAtMs,
+ meta.workStartedAtMs,
+ meta.workedFor?.startedAtMs,
+ settings.firstTurnWorkItemStartedAtMs,
+ stateSnapshot.firstTurnWorkItemStartedAtMs,
+ stateSnapshot.workStartedAtMs,
+ );
+ if (metadataWorkStart !== null) state.turnWorkStartedAt = metadataWorkStart;
+ const metadataFinalStart = timestampMs(
+ meta.finalAssistantStartedAtMs,
+ meta.workedFor?.completedAtMs,
+ settings.finalAssistantStartedAtMs,
+ stateSnapshot.finalAssistantStartedAtMs,
+ );
+ if (metadataFinalStart !== null) state.finalAssistantStartedAt = metadataFinalStart;
+ renderModelPicker();
+ renderSubagents();
+ renderUsage();
+
+ const permissionsValue = meta.permissions ?? meta.currentPermissions ?? settings.permissions
+ ?? stateSnapshot.permissions ?? stateSnapshot.currentPermissions;
+ const sandboxValue = meta.sandboxPolicy ?? meta.sandbox ?? settings.sandboxPolicy ?? settings.sandbox
+ ?? stateSnapshot.sandboxPolicy ?? stateSnapshot.sandbox ?? sandboxFromPermissions(permissionsValue);
+ const sandbox = typeof sandboxValue === "object" && sandboxValue !== null
+ ? firstString(sandboxValue.type, sandboxValue.mode, sandboxValue.policy)
+ : firstString(sandboxValue);
+ if (sandbox) {
+ const normalizedSandbox = normalizeSandboxName(sandbox);
+ state.sandboxPolicy = normalizedSandbox;
+ const sandboxInput = $("sandboxInput");
+ if (sandboxInput && [...sandboxInput.options].some((option) => option.value === normalizedSandbox)) sandboxInput.value = normalizedSandbox;
+ const permission = $("permissionChip");
+ const permissionLabel = $("permissionLabel");
+ if (permission && permissionLabel) {
+ permissionLabel.textContent = permissionSandboxLabel(normalizedSandbox);
+ permission.hidden = false;
+ }
+ } else {
+ const permission = $("permissionChip");
+ if (permission) permission.hidden = false;
+ }
+ const approval = normalizeApprovalPolicy(
+ meta.approvalPolicy !== undefined ? meta.approvalPolicy
+ : settings.approvalPolicy !== undefined ? settings.approvalPolicy
+ : stateSnapshot.approvalPolicy,
+ );
+ if (approval) {
+ state.approvalPolicy = approval;
+ const approvalInput = $("approvalInput");
+ if (approvalInput && [...approvalInput.options].some((option) => option.value === approval)) approvalInput.value = approval;
+ }
+ renderPermissionMenu();
+ const mode = firstString(meta.mode, stateSnapshot.mode);
+ const modeLabel = $("modeLabel");
+ if (modeLabel && mode) modeLabel.textContent = t(/cloud|remote/i.test(mode) ? "云端模式" : "本地模式");
+ const popoverCwd = $("popoverCwd");
+ const popoverMode = $("popoverMode");
+ if (popoverCwd && cwd) popoverCwd.textContent = cwd;
+ if (popoverMode && mode) popoverMode.textContent = t(/cloud|remote/i.test(mode) ? "云端模式" : "本地模式");
+ }
+
+ const setConnection = (kind, text) => {
+ $("connectionDot").className = `dot ${kind}`;
+ $("connectionText").textContent = t(text);
+ if (embeddedInAether) embedBridge.reportState(kind, { message: String(text || "") });
+ };
+
+ function setAuthRequired(value) {
+ if (typeof value !== "boolean") return;
+ state.authRequired = value;
+ document.body.classList.toggle("local-no-auth", !value);
+ const label = $("tokenLabel");
+ const input = $("tokenInput");
+ if (!label || !input) return;
+ label.textContent = t(value ? "访问 token(认证模式)" : "本机连接(无需 token)");
+ input.placeholder = t(value ? "粘贴 relay 启动时打印的 token" : "本机模式无需填写;认证模式再填写");
+ input.setAttribute("aria-label", t(value ? "relay access token" : "本地连接无需 token"));
+ }
+
+ const authHeaders = () => ({ Authorization: `Bearer ${state.token}`, "Content-Type": "application/json" });
+
+ function attachPendingUserToTurn(turnId) {
+ const key = String(turnId || "");
+ if (!key) return;
+ const article = state.pendingUserArticle;
+ if (article && article.isConnected) article.dataset.turnId = key;
+ state.pendingUserArticle = null;
+ }
+
+ function sendFrame(frame) {
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) throw new Error(t("WebSocket 未连接"));
+ state.ws.send(JSON.stringify(frame));
+ }
+
+ function command(method, params) {
+ const commandId = `web-${crypto.randomUUID()}`;
+ sendFrame({ type: "command", commandId, method, params });
+ if (method === "turn/start" || method === "turn/steer") {
+ const text = (params?.input || [])
+ .map((item) => typeof item === "string" ? item : item?.text)
+ .filter(Boolean)
+ .join("\n");
+ finishAssistantStream();
+ state.pendingUserText = text;
+ appendDateSeparator(Date.now(), { role: "user", turnId: state.turnId || params?.expectedTurnId || "", turnStart: true });
+ const userMessage = appendMessage(text || "(空消息)", "user", "text", "", {
+ kind: "user",
+ turnId: state.turnId || params?.expectedTurnId || "",
+ timestamp: Date.now(),
+ showTimestamp: true,
+ });
+ if (method === "turn/start") state.pendingUserArticle = userMessage?.article || null;
+ } else if (method === "thread/settings/update") {
+ // Keep settings changes quiet in the transcript. The model picker has
+ // its own pending state and the authoritative snapshot will update the
+ // label once the official follower accepts the request.
+ setConversationStatus("正在更新模型设置", "active");
+ } else if (["session/list", "session/select", "control/mode/set"].includes(sessionCommandMethod(method))) {
+ // Session navigation is shell UI state. It must not appear as a Codex
+ // message or alter the current turn's activity timeline.
+ } else {
+ appendOutput(`${method} (${commandId})`, "meta");
+ }
+ return commandId;
+ }
+
+ function composerText() {
+ const editor = $("messageInput");
+ if (!editor) return "";
+ return String(editor.innerText || editor.textContent || "")
+ .replace(/\u00a0/g, " ")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+ }
+
+ function clearComposer() {
+ const editor = $("messageInput");
+ if (!editor) return;
+ editor.replaceChildren();
+ editor.style.height = "";
+ resizeComposer();
+ updateIds();
+ }
+
+ function resizeComposer() {
+ const editor = $("messageInput");
+ if (!editor) return;
+ editor.style.height = "auto";
+ const maxHeight = Math.max(40, Math.round(window.innerHeight * 0.25));
+ const nextHeight = Math.min(Math.max(editor.scrollHeight, 40), maxHeight);
+ editor.style.height = `${nextHeight}px`;
+ updateScrollPadding();
+ }
+
+ function defaultResponse(request) {
+ const method = request.method;
+ // Rendering a request must never imply consent. The operator still has
+ // to press an explicit action button.
+ if (method === "item/commandExecution/requestApproval") return { decision: "decline" };
+ if (method === "item/fileChange/requestApproval") return { decision: "decline" };
+ if (method === "item/permissions/requestApproval") {
+ return normalizePermissionResponse({});
+ }
+ if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: { denied: { rejection: "默认拒绝,请明确允许" } } };
+ if (method === "item/tool/requestUserInput") {
+ const answers = {};
+ for (const question of request.params?.questions || []) answers[question.id] = { answers: [""] };
+ return { answers };
+ }
+ if (method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null };
+ return {};
+ }
+
+ function normalizePermissionResponse(rawPermissions, scope, strictAutoReview) {
+ const permissions = {};
+ if (rawPermissions && typeof rawPermissions === "object" && !Array.isArray(rawPermissions)) {
+ for (const [key, value] of Object.entries(rawPermissions)) {
+ if (value && typeof value === "object" && !Array.isArray(value)) permissions[key] = value;
+ }
+ }
+ const response = { permissions, scope: scope === "session" ? "session" : "turn" };
+ if (typeof strictAutoReview === "boolean") response.strictAutoReview = strictAutoReview;
+ return response;
+ }
+
+ function requestSummary(request) {
+ const params = request.params || {};
+ if (Array.isArray(params.commandActions)) {
+ const commands = params.commandActions
+ .map((action) => action && typeof action === "object" ? action.command || action.description : "")
+ .filter(Boolean);
+ if (commands.length) return commands.join("\n");
+ }
+ if (request.summary) return request.summary;
+ if (Array.isArray(params.command)) return params.command.join(" ");
+ if (params.command) return params.command;
+ if (params.reason) return params.reason;
+ if (Array.isArray(params.questions)) return params.questions.map((q) => q.question).join(" / ");
+ if (params.message) return params.message;
+ return t("需要远程确认或输入");
+ }
+
+ function requestTitle(request) {
+ const method = String(request.method || "");
+ if (method === "item/commandExecution/requestApproval" || method === "execCommandApproval") return t("允许运行命令?");
+ if (method === "item/fileChange/requestApproval" || method === "applyPatchApproval") return t("允许修改文件?");
+ if (method === "item/permissions/requestApproval") return t("需要扩大权限");
+ if (method === "item/tool/requestUserInput") return t("Codex 需要你的回答");
+ if (method === "mcpServer/elicitation/request") return t("需要外部服务确认");
+ return t("Codex 请求确认");
+ }
+
+ function requestRisk(request) {
+ const risk = String(request.risk || "medium").toLowerCase();
+ return t(risk === "high" ? "高风险" : risk === "low" ? "低风险" : "需确认");
+ }
+
+ function requestCommand(request) {
+ const params = request.params || {};
+ if (Array.isArray(params.commandActions)) {
+ return params.commandActions
+ .map((action) => action && typeof action === "object" ? action.command || action.description : "")
+ .filter(Boolean)
+ .join("\n");
+ }
+ if (Array.isArray(params.command)) return params.command.join(" ");
+ return typeof params.command === "string" ? params.command : "";
+ }
+
+ function questionOptions(question) {
+ const options = question?.options || question?.choices || question?.enum;
+ if (!Array.isArray(options)) return [];
+ return options.map((option) => {
+ if (typeof option === "string") return { label: option, value: option };
+ if (option && typeof option === "object") {
+ const value = option.value ?? option.id ?? option.label ?? option.name;
+ const label = option.label ?? option.name ?? value;
+ return { label: String(label ?? ""), value: String(value ?? "") };
+ }
+ return null;
+ }).filter((option) => option && option.value);
+ }
+
+ function renderQuestionFields(container, request) {
+ const questions = Array.isArray(request.params?.questions) ? request.params.questions : [];
+ container.replaceChildren();
+ if (!questions.length) {
+ container.hidden = true;
+ return;
+ }
+ container.hidden = false;
+ questions.forEach((question, index) => {
+ if (!question || typeof question !== "object") return;
+ const field = document.createElement("label");
+ field.className = "request-question";
+ field.dataset.questionId = String(question.id ?? question.key ?? index);
+ const prompt = document.createElement("span");
+ const questionPrompt = question.question ?? question.prompt ?? question.label;
+ prompt.textContent = questionPrompt === undefined || questionPrompt === null ? t("请输入") : String(questionPrompt);
+ field.append(prompt);
+ const options = questionOptions(question);
+ let control;
+ if (options.length) {
+ control = document.createElement("select");
+ options.forEach((option) => {
+ const item = document.createElement("option");
+ item.value = option.value;
+ item.textContent = option.label;
+ control.append(item);
+ });
+ } else {
+ control = document.createElement("input");
+ control.type = question.secret ? "password" : "text";
+ control.placeholder = String(question.placeholder ?? "");
+ }
+ control.className = "request-answer";
+ control.dataset.answerId = field.dataset.questionId;
+ field.append(control);
+ container.append(field);
+ });
+ }
+
+ function inputResponseFromCard(article) {
+ const answers = {};
+ for (const field of article.querySelectorAll(".request-question")) {
+ const id = field.dataset.questionId;
+ const value = field.querySelector(".request-answer")?.value ?? "";
+ answers[id] = { answers: [value] };
+ }
+ return { answers };
+ }
+
+ function requestResponseFromCard(request, article, responseBox, action) {
+ if (request.method === "item/tool/requestUserInput" && action === "allow") {
+ // Prefer explicit edits in the JSON editor; otherwise collect the
+ // first-class answer controls rendered for each question.
+ if (responseBox.value !== article.dataset.defaultResponse) {
+ try {
+ const parsed = JSON.parse(responseBox.value);
+ if (parsed && typeof parsed === "object") return parsed;
+ } catch {
+ return null;
+ }
+ }
+ return inputResponseFromCard(article);
+ }
+ if (action === "allow") {
+ const response = allowResponse(request, responseBox.value);
+ const scope = article.querySelector(".request-scope")?.value;
+ if (request.method === "item/permissions/requestApproval" && response && typeof response === "object" && scope) response.scope = scope;
+ return response;
+ }
+ if (action === "deny") return denyResponse(request);
+ try {
+ const parsed = JSON.parse(responseBox.value);
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return null;
+ }
+ }
+
+ function buildRequestCard(request) {
+ const fragment = $("requestTemplate").content.cloneNode(true);
+ const article = fragment.querySelector(".request");
+ const method = String(request.method || "unknown");
+ const risk = requestRisk(request);
+ article.dataset.requestKey = requestKey(request.requestId);
+ article.dataset.risk = String(request.risk || "medium").toLowerCase();
+ fragment.querySelector(".request-method").textContent = requestTitle(request);
+ fragment.querySelector(".request-id").textContent = `#${request.requestId}`;
+ const riskNode = fragment.querySelector(".request-risk");
+ riskNode.textContent = risk;
+ riskNode.dataset.risk = article.dataset.risk;
+ fragment.querySelector(".request-summary").textContent = requestSummary(request);
+ const commandNode = fragment.querySelector(".request-command");
+ const commandText = requestCommand(request);
+ commandNode.textContent = commandText;
+ commandNode.hidden = !commandText;
+ fragment.querySelector(".request-json").textContent = JSON.stringify(request.params || {}, null, 2);
+ const responseBox = fragment.querySelector(".request-response");
+ responseBox.value = JSON.stringify(defaultResponse(request), null, 2);
+ article.dataset.defaultResponse = responseBox.value;
+ const scopeWrap = fragment.querySelector(".request-scope-wrap");
+ if (request.method === "item/permissions/requestApproval") scopeWrap.hidden = false;
+ renderQuestionFields(fragment.querySelector(".request-questions"), request);
+ const allow = fragment.querySelector(".request-allow");
+ const deny = fragment.querySelector(".request-deny");
+ const send = fragment.querySelector(".request-send");
+ allow.textContent = t(method === "item/tool/requestUserInput" ? "提交回答" : method === "item/permissions/requestApproval" ? "允许" : "允许一次");
+ send.textContent = t("发送自定义响应");
+ allow.addEventListener("click", () => {
+ const result = requestResponseFromCard(request, article, responseBox, "allow");
+ if (result) respond(request.requestId, JSON.stringify(result));
+ else appendOutput("自定义响应不是有效 JSON", "error");
+ });
+ deny.addEventListener("click", () => respond(request.requestId, JSON.stringify(requestResponseFromCard(request, article, responseBox, "deny"))));
+ send.addEventListener("click", () => {
+ const result = requestResponseFromCard(request, article, responseBox, "custom");
+ if (result) respond(request.requestId, JSON.stringify(result));
+ else appendOutput("响应不是有效 JSON", "error");
+ });
+ if (state.sessionSwitching || state.modeSwitching || state.role !== "operator" || !RESPONDABLE_METHODS.has(method) || state.responding.has(requestKey(request.requestId))) {
+ for (const button of fragment.querySelectorAll("button")) button.disabled = true;
+ responseBox.disabled = true;
+ for (const control of fragment.querySelectorAll("input, select")) control.disabled = true;
+ }
+ return fragment;
+ }
+
+ function renderRequests() {
+ const container = $("inlineRequests");
+ const legacyContainer = $("requests");
+ container.replaceChildren();
+ if (legacyContainer) legacyContainer.replaceChildren();
+ const requests = [...state.requests.values()];
+ $("requestCount").textContent = String(requests.length);
+ $("factRequests").textContent = String(requests.length);
+ renderControlMode();
+ const panel = $("requestsPanel");
+ if (!requests.length) {
+ container.className = "inline-requests empty";
+ if (legacyContainer) {
+ legacyContainer.className = "requests empty";
+ legacyContainer.textContent = t("暂无待处理请求");
+ }
+ if (panel) panel.open = false;
+ return;
+ }
+ container.className = "inline-requests";
+ for (const request of requests) {
+ const fragment = buildRequestCard(request);
+ container.append(fragment);
+ }
+ if (panel) panel.open = false;
+ }
+
+ function denyResponse(request) {
+ if (request.method === "item/commandExecution/requestApproval" || request.method === "item/fileChange/requestApproval") return { decision: "decline" };
+ if (request.method === "item/permissions/requestApproval") return normalizePermissionResponse({});
+ if (request.method === "applyPatchApproval" || request.method === "execCommandApproval") return { decision: { denied: { rejection: "远程参与者拒绝" } } };
+ if (request.method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null };
+ if (request.method === "item/tool/requestUserInput") return { answers: {} };
+ return { decision: "decline" };
+ }
+
+ function allowResponse(request, raw) {
+ const method = request.method;
+ if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { decision: "accept" };
+ if (method === "item/permissions/requestApproval") {
+ if (raw === undefined) return normalizePermissionResponse(request.params?.permissions);
+ try {
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === "object"
+ ? normalizePermissionResponse(parsed.permissions, parsed.scope, parsed.strictAutoReview)
+ : normalizePermissionResponse({});
+ } catch {
+ return normalizePermissionResponse(request.params?.permissions);
+ }
+ }
+ if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: "approved" };
+ if (method === "mcpServer/elicitation/request") return { action: "accept", content: null, _meta: null };
+ // User-input requests need the operator's edited answers. Keep the JSON
+ // editor as the source of truth and fail closed if it is malformed.
+ try {
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === "object" ? parsed : { answers: {} };
+ } catch {
+ return { answers: {} };
+ }
+ }
+
+ function respond(requestId, raw) {
+ if (state.sessionSwitching) return;
+ const key = requestKey(requestId);
+ if (state.responding.has(key)) return;
+ let result;
+ try { result = JSON.parse(raw); } catch { appendOutput("响应不是有效 JSON", "error"); return; }
+ try { sendFrame({ type: "respond", requestId, result }); } catch (error) { appendOutput(error.message, "error"); return; }
+ state.responding.add(key);
+ renderRequests();
+ }
+
+ function updateIds() {
+ $("threadId").textContent = state.threadId || "-";
+ $("turnId").textContent = state.turnId || "-";
+ const popoverThread = $("popoverThread");
+ if (popoverThread) popoverThread.textContent = state.threadId || "-";
+ const hasThread = Boolean(state.threadId);
+ const hasTurn = Boolean(state.turnId);
+ const hasComposerText = Boolean(composerText());
+ const switching = Boolean(state.sessionSwitching || state.modeSwitching);
+ document.body.classList.toggle("turn-active", hasTurn);
+ $("startThreadButton").disabled = state.attachMode || !state.appReady || state.role !== "operator";
+ const newSessionButton = $("newSessionButton");
+ if (newSessionButton) {
+ newSessionButton.disabled = !sessionControlAllowed("sessionCreate") || switching || !state.appReady
+ || Boolean(state.newSessionCommandId)
+ || !["operator", "owner", "host"].includes(String(state.role || ""));
+ newSessionButton.setAttribute("aria-busy", String(Boolean(state.newSessionCommandId)));
+ }
+ $("startTurnButton").disabled = switching || !state.appReady || !hasThread || hasTurn || !hasComposerText || state.role !== "operator";
+ $("steerButton").disabled = switching || !state.appReady || !hasTurn || !hasComposerText || state.role !== "operator";
+ $("interruptButton").disabled = switching || !state.appReady || !hasTurn || state.role !== "operator";
+ const messageInput = $("messageInput");
+ if (messageInput) {
+ messageInput.contentEditable = switching ? "false" : "true";
+ messageInput.setAttribute("aria-disabled", String(switching));
+ }
+ for (const id of ["modelPickerButton", "permissionChip", "composerPlusButton"]) {
+ const control = $(id);
+ if (control) control.disabled = switching;
+ }
+ const send = $("startTurnButton");
+ if (send) {
+ send.title = t(hasTurn ? "发送 Steer" : "发送消息");
+ send.setAttribute("aria-label", t(hasTurn ? "发送 Steer" : "发送消息"));
+ }
+ const settingsModelValue = $("settingsModelValue");
+ if (settingsModelValue) settingsModelValue.textContent = state.currentModel ? modelDisplayName(state.currentModel, state.currentEffort) : t("默认");
+ const settingsPermissionValue = $("settingsPermissionValue");
+ if (settingsPermissionValue) settingsPermissionValue.textContent = permissionSandboxLabel(state.sandboxPolicy);
+ renderControlMode();
+ }
+
+ function protocolMethodForEvent(event, payload) {
+ if (typeof payload?.method === "string") return payload.method;
+ if (typeof payload?.params?.method === "string") return payload.params.method;
+ const type = String(event?.type || "");
+ if (type === "item.started") return "item/started";
+ if (type === "item.completed") return "item/completed";
+ if (type === "thread.started") return "thread/started";
+ if (type === "turn.started") return "turn/started";
+ if (type === "turn.completed") return "turn/completed";
+ if (type === "turn.plan.updated") return "turn/plan/updated";
+ if (type === "turn.diff.updated") return "turn/diff/updated";
+ return "";
+ }
+
+ function handleProtocolNotification(method, payload, eventType = "") {
+ const params = eventParams(payload);
+ if (method === "item/started" || eventType === "item.started") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ handleItemLifecycle("started", params);
+ return true;
+ }
+ if (method === "item/completed" || eventType === "item.completed") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ handleItemLifecycle("completed", params);
+ return true;
+ }
+ if (method === "turn/plan/updated" || method === "turn/plan/update" || eventType === "turn.plan.updated") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ handlePlanUpdate(params);
+ return true;
+ }
+ if (method === "turn/diff/updated" || method === "turn/diff/update" || eventType === "turn.diff.updated") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ handleDiffUpdate(params);
+ return true;
+ }
+ if (/^(?:item\/)?fileChange\/outputDelta$/.test(method)
+ || method === "item/fileChange/delta") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ appendFileChangeChunk(payload, textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk));
+ return true;
+ }
+ if (/^(?:item\/)?(?:fileRead|readFile|fileReadOutput)\/(?:outputDelta|delta|textDelta)$/.test(method)
+ || method === "item/fileRead/outputDelta"
+ || method === "item/fileRead/delta") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ appendOutputChunk(textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk ?? params.content), "read", {
+ kind: "read",
+ itemId: params.itemId,
+ turnId: eventTurnId(payload),
+ });
+ return true;
+ }
+ if (method === "item/commandExecution/outputDelta"
+ || method === "command/exec/outputDelta"
+ || method === "process/outputDelta") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ const stream = params.stream || params.channel || (params.stderr ? "stderr" : "stdout");
+ appendOutputChunk(textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk), stream, {
+ kind: "tool",
+ itemId: params.itemId,
+ turnId: eventTurnId(payload),
+ });
+ return true;
+ }
+ if (method === "item/reasoning/summaryTextDelta"
+ || method === "item/reasoning/textDelta"
+ || method === "item/plan/delta") {
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ appendOutputChunk(textFromValue(params.delta ?? params.text), "reasoning", {
+ kind: method === "item/plan/delta" ? "plan" : "reasoning",
+ itemId: params.itemId,
+ turnId: eventTurnId(payload),
+ });
+ return true;
+ }
+ if (method === "turn/started") {
+ const turn = isRecord(params.turn) ? params.turn : params;
+ const startedTurnId = turn.id || params.turnId;
+ const workStart = timestampMs(
+ turn.firstTurnWorkItemStartedAtMs,
+ turn.workStartedAtMs,
+ params.firstTurnWorkItemStartedAtMs,
+ params.workStartedAtMs,
+ );
+ if (workStart !== null) state.turnWorkStartedAt = workStart;
+ startTurnClock(startedTurnId, turn.startedAtMs || params.startedAtMs, turn.elapsedMs || params.elapsedMs);
+ attachPendingUserToTurn(startedTurnId);
+ return true;
+ }
+ if (method === "turn/completed") {
+ const turn = isRecord(params.turn) ? params.turn : params;
+ const turnId = turn.id || params.turnId || "";
+ if (!eventBelongsToCurrentTurn(payload)) return true;
+ const status = normalizeActivityStatus(turn.status || params.status, "completed");
+ const duration = turn.durationMs || params.durationMs;
+ const worked = workedDurationFor(turn, workedDurationFor(params, null));
+ const finalAssistantStart = timestampMs(turn.finalAssistantStartedAtMs, params.finalAssistantStartedAtMs);
+ if (finalAssistantStart !== null) state.finalAssistantStartedAt = finalAssistantStart;
+ finishActivitiesForTurn(turnId, status);
+ finishAssistantStream();
+ const completedTurnId = turnId || state.turnId || "";
+ stopTurnClock(status, duration, turn.completedAtMs || params.completedAtMs, worked);
+ appendCompletedTurnDivider(completedTurnId, status, worked ?? state.lastWorkedDurationMs ?? duration ?? state.lastTurnDurationMs);
+ reconcileTurnDividers();
+ if (completedTurnId) {
+ state.retiredTurnIds.add(completedTurnId);
+ if (state.retiredTurnIds.size > 100) state.retiredTurnIds.delete(state.retiredTurnIds.values().next().value);
+ }
+ state.pendingUserText = "";
+ state.turnId = "";
+ updateIds();
+ return true;
+ }
+ return false;
+ }
+
+ function handleEvent(event) {
+ const eventSeq = finiteNumber(event.seq);
+ // The relay sends a buffered event stream followed by one control
+ // `session.snapshot`. Treat that control frame as the only baseline during
+ // the handshake; an older host `session.snapshot` event in the buffer is
+ // just historical data and may describe a turn that already ended.
+ if (state.awaitingSnapshot) return;
+ // `subscribe` may replay events that are already represented by the
+ // following authoritative snapshot. Ignore those frames entirely so a
+ // stale task.started/task.status cannot reopen the composer state.
+ if (eventSeq !== null && state.lastSnapshotSeq > 0 && eventSeq <= state.lastSnapshotSeq) return;
+ if (eventSeq !== null && eventSeq > state.lastSeq) state.lastSeq = eventSeq;
+ $("latestSeq").textContent = `seq ${state.lastSeq}`;
+ $("lastEvent").textContent = `${event.seq || "-"} / ${event.type || "event"}`;
+ const payload = isRecord(event.payload) ? { ...event.payload } : {};
+ // Older bridge versions put identity/status fields on the event envelope
+ // instead of inside payload. Normalize both shapes before routing so a
+ // terminal event cannot be attributed to the wrong turn.
+ for (const key of [
+ "threadId", "turnId", "requestId", "method", "params", "status", "executionStatus", "activity", "turnStatus", "activeFlags",
+ "controlMode", "mode", "targetMode", "modeEpoch", "capabilities",
+ "startedAtMs", "durationMs", "completedAtMs", "workedDurationMs", "workDurationMs", "workedForMs",
+ "firstTurnWorkItemStartedAtMs", "firstWorkItemStartedAtMs", "workStartedAtMs", "finalAssistantStartedAtMs",
+ ]) {
+ if (payload[key] === undefined && event[key] !== undefined) payload[key] = event[key];
+ }
+ const usagePayload = payload.tokenUsage ?? payload.latestTokenUsageInfo ?? payload.contextUsage ?? payload.usage
+ ?? payload.params?.tokenUsage ?? payload.params?.latestTokenUsageInfo ?? payload.params?.contextUsage;
+ if (usagePayload !== undefined) {
+ state.tokenUsage = usagePayload;
+ renderUsage();
+ }
+ const protocolMethod = protocolMethodForEvent(event, payload);
+ // Do not let a replayed terminal event from an older turn overwrite the
+ // timer/status of a newer turn. The protocol handler performs the same
+ // check, but status is normally applied before routing the event.
+ const staleTerminalEvent = (event.type === "task.finished"
+ || event.type === "task.cancelled"
+ || protocolMethod === "turn/completed")
+ && !eventBelongsToCurrentTurn(payload);
+ // Status is carried both as a typed relay field and inside the payload for
+ // older clients. Apply it before routing the event so a normal output
+ // delta cannot hide an active thinking/editing/approval state.
+ const hasExecutionStatus = isRecord(payload.executionStatus)
+ || isRecord(event.status)
+ || isRecord(payload.status)
+ || typeof payload.activity === "string"
+ || typeof payload.turnStatus === "string"
+ || Array.isArray(payload.activeFlags)
+ || payload.startedAtMs !== undefined
+ || payload.durationMs !== undefined;
+ if (hasExecutionStatus && !staleTerminalEvent) {
+ applyStatusSnapshot({
+ ...payload,
+ status: payload.executionStatus || event.status || payload.status,
+ }, { allowTerminal: true, showIdle: false });
+ }
+ if (protocolMethod && handleProtocolNotification(protocolMethod, payload, event.type)) {
+ if (event.type === "item.started"
+ || event.type === "item.completed"
+ || event.type === "task.status"
+ || protocolMethod === "turn/completed") updateIds();
+ return;
+ }
+ if (event.type === "control.mode.switching" || event.type === "control.mode.changed") {
+ const requestedMode = normalizeControlMode(firstString(payload.controlMode, payload.mode, payload.targetMode));
+ if (requestedMode && (requestedMode !== state.controlMode || state.modeSwitching)) {
+ state.modeSwitching = true;
+ state.requestedControlMode = requestedMode;
+ if (state.modeRequestEpoch < 0) state.modeRequestEpoch = state.modeEpoch;
+ setConversationStatus("正在切换控制模式", "active");
+ updateIds();
+ }
+ return;
+ }
+ if (event.type === "session.switching") {
+ state.sessionSwitching = true;
+ const targetThreadId = firstString(payload.targetThreadId, payload.threadId);
+ let switchContext = state.sessionSwitchContext;
+ if (targetThreadId) {
+ state.sessionSelectedThreadId = targetThreadId;
+ switchContext = beginSessionSwitchContext(targetThreadId);
+ // Route subsequent target events against the requested owner while
+ // the previous transcript remains mounted as the visual fallback.
+ state.threadId = targetThreadId;
+ if (switchContext?.targetTitle) {
+ setConversationStatus(
+ uiLocale() === "en-US"
+ ? `Switching to “${switchContext.targetTitle}”`
+ : `正在切换到「${switchContext.targetTitle}」`,
+ "active",
+ );
+ }
+ }
+ finishAssistantStream();
+ // Keep the previous transcript mounted until the target's authoritative
+ // snapshot arrives. The bridge may emit `session.switching` before
+ // owner discovery/follow completes; clearing here made a failed switch
+ // look like an empty conversation and left the user with no way to tell
+ // whether the target had actually loaded.
+ setSessionSwitchingVisual(true);
+ if (!switchContext?.targetTitle) setConversationStatus("正在切换会话", "active");
+ renderSessionPicker();
+ return;
+ }
+ if (event.type === "session.selected") {
+ const selectedThreadId = firstString(payload.threadId, payload.activeThreadId);
+ const switchContext = state.sessionSwitchContext;
+ if (payload.failed === true) {
+ // VS Code-driven attachment changes have no browser command result.
+ // The adapter therefore publishes an explicit failed selection for
+ // the previous thread. Roll routing/title back even if a target
+ // snapshot was already rendered while the adapter validated its owner.
+ const previousThreadId = firstString(switchContext?.previousThreadId, selectedThreadId);
+ restoreSessionSwitchContext();
+ if (previousThreadId) {
+ state.threadId = previousThreadId;
+ state.sessionSelectedThreadId = previousThreadId;
+ syncSessionActive(previousThreadId);
+ }
+ state.sessionListError = sessionErrorMessage(payload, "会话切换失败,已恢复原会话");
+ setConversationStatus(state.sessionListError, "warning");
+ renderSessionPicker();
+ return;
+ }
+
+ const matchesTarget = Boolean(switchContext
+ && selectedThreadId
+ && switchContext.targetThreadId === selectedThreadId);
+ if (selectedThreadId && (!switchContext || matchesTarget)) {
+ state.sessionSelectedThreadId = selectedThreadId;
+ syncSessionActive(selectedThreadId);
+ }
+ // This is only one half of the switch commit. An acknowledgement for a
+ // superseded target is ignored, and a matching acknowledgement keeps all
+ // input disabled until the target's authoritative snapshot is committed.
+ if (matchesTarget) {
+ switchContext.selectedAckReady = true;
+ if (finishSessionSwitchIfReady()) {
+ setConversationStatus("会话已切换", "ready");
+ } else {
+ state.sessionSwitching = true;
+ setSessionSwitchingVisual(true);
+ setConversationStatus("正在加载会话", "active");
+ }
+ } else if (!switchContext) {
+ state.sessionSwitching = false;
+ finishSessionSwitchContext();
+ }
+ renderSessionPicker();
+ return;
+ }
+ if (/error|warning/i.test(String(event.type || ""))
+ || ["error", "warning"].includes(String(payload.method || "").toLowerCase())) {
+ finishAssistantStream();
+ appendOutput(eventMessage(payload), /warning/i.test(String(event.type || "")) ? "meta" : "error");
+ return;
+ }
+ if (event.type === "connection.opened" || event.type === "app.ready") {
+ state.appReady = true;
+ $("appState").textContent = appStatusLabel("ready");
+ $("factApp").textContent = appStatusLabel("ready");
+ }
+ if (event.type === "connection.closed" || event.type === "host.disconnected" || event.type === "app.exited") {
+ finishAssistantStream();
+ const disconnectedTurnId = state.turnId;
+ if (disconnectedTurnId) finishActivitiesForTurn(disconnectedTurnId, "interrupted");
+ state.pendingUserText = "";
+ state.appReady = false;
+ state.snapshotNoticeShown = false;
+ state.turnId = "";
+ state.turnStartedAt = null;
+ state.turnWorkStartedAt = null;
+ state.finalAssistantStartedAt = null;
+ state.workedDurationMs = null;
+ state.lastWorkedDurationMs = null;
+ state.currentActivity = "idle";
+ state.currentActivityStartedAt = null;
+ state.currentActivityDurationMs = null;
+ state.currentActivityTurnId = "";
+ state.subagents = [];
+ renderSubagents();
+ updateLiveActivity("idle");
+ state.sessionListLoading = false;
+ state.sessionListCommandId = "";
+ state.newSessionCommandId = "";
+ state.sessions = [];
+ state.sessionFocusedId = "";
+ if (state.sessionSwitching) {
+ // An explicit host disconnect aborts an in-flight hand-off. Restore
+ // the previous thread identity while keeping its transcript mounted.
+ restoreSessionSwitchContext();
+ }
+ state.sessionSelectedThreadId = "";
+ state.sessionListError = "VS Code 主机未连接";
+ renderSessionPicker();
+ $("appState").textContent = appStatusLabel("offline");
+ $("factApp").textContent = appStatusLabel("offline");
+ updateIds();
+ updateScrollToBottom($("output"));
+ }
+ if (event.type === "output.snapshot") {
+ const snapshotThreadId = firstString(payload.threadId, state.threadId, state.syncedThreadId);
+ if (!outputProjectionAllowed(snapshotThreadId)) return;
+ const committed = commitOutputProjection(snapshotThreadId, typeof payload.text === "string" ? payload.text : "", payload.messages, {
+ historyComplete: snapshotHistoryComplete(payload),
+ authoritativeSnapshot: true,
+ });
+ if (!committed) return;
+ if (Array.isArray(payload.subagents)) {
+ state.subagents = payload.subagents;
+ renderSubagents();
+ }
+ return;
+ }
+ if ((event.type === "output.delta" || event.type === "output.chunk")
+ && (payload.text || Array.isArray(payload.messages) || isRecord(payload.messagesPatch))) {
+ const projectionThreadId = firstString(payload.threadId, state.threadId, state.syncedThreadId);
+ if (!outputProjectionAllowed(projectionThreadId)) return;
+ if (!eventBelongsToCurrentTurn(payload)) return;
+ const projectionMatches = !projectionThreadId || projectionThreadId === state.syncedThreadId;
+ // Attach-mode adapters include the complete role-aware projection on a
+ // delta. Re-rendering that projection keeps reasoning, tools, edits and
+ // assistant text in their canonical item boundaries while retaining the
+ // legacy append-only text field for older bridges.
+ if (Array.isArray(payload.messages)) {
+ if (!projectionMatches) {
+ const committed = commitOutputProjection(
+ projectionThreadId,
+ typeof payload.outputTail === "string" ? payload.outputTail : typeof payload.text === "string" ? payload.text : "",
+ payload.messages,
+ { historyComplete: snapshotHistoryComplete(payload) },
+ );
+ if (committed && Array.isArray(payload.subagents)) {
+ state.subagents = payload.subagents;
+ renderSubagents();
+ }
+ return;
+ }
+ if (Array.isArray(payload.subagents)) {
+ state.subagents = payload.subagents;
+ renderSubagents();
+ }
+ if (payload.structureChanged === false && reconcileStructuredOutput(
+ typeof payload.outputTail === "string" ? payload.outputTail : payload.text,
+ payload.messages,
+ )) return;
+ replaceOutput(
+ typeof payload.outputTail === "string" ? payload.outputTail : typeof payload.text === "string" ? payload.text : "",
+ payload.messages,
+ );
+ return;
+ }
+ if (isRecord(payload.messagesPatch)) {
+ // A suffix patch has meaning only relative to the same thread's
+ // authoritative baseline. Never apply it to the transcript retained
+ // while another session is still loading.
+ if (!projectionMatches) return;
+ if (Array.isArray(payload.subagents)) {
+ state.subagents = payload.subagents;
+ renderSubagents();
+ }
+ const patchedMessages = applyStructuredMessagesPatch(payload.messagesPatch);
+ if (patchedMessages) {
+ const outputTail = typeof payload.outputTail === "string"
+ ? payload.outputTail
+ : typeof payload.text === "string"
+ ? `${$("output")?.dataset.outputTail || ""}${payload.text}`.slice(-32_000)
+ : $("output")?.dataset.outputTail || "";
+ if (reconcileStructuredOutput(outputTail, patchedMessages)) return;
+ replaceOutput(outputTail, patchedMessages);
+ return;
+ }
+ }
+ if (!projectionMatches) return;
+ if (Array.isArray(payload.subagents)) {
+ state.subagents = payload.subagents;
+ renderSubagents();
+ }
+ appendOutputChunk(payload.text, payload.stream, {
+ kind: payload.kind,
+ itemId: payload.itemId,
+ turnId: eventTurnId(payload),
+ timestamp: payload.timestamp || payload.startedAtMs,
+ });
+ return;
+ }
+ if (event.type === "app.stderr") {
+ finishAssistantStream();
+ appendOutput(payload.text, "meta");
+ return;
+ }
+ if (event.type === "approval.requested" || event.type === "input.requested" || event.type === "server.requested" || event.type === "server.request") {
+ state.requests.set(requestKey(payload.requestId), {
+ requestId: payload.requestId,
+ method: payload.method,
+ params: payload.params || payload,
+ ...(payload.risk ? { risk: payload.risk } : {}),
+ ...(payload.summary ? { summary: payload.summary } : {}),
+ ...(payload.commandHash ? { commandHash: payload.commandHash } : {}),
+ ...(payload.createdAt ? { createdAt: payload.createdAt } : {}),
+ ...(payload.expiresAt ? { expiresAt: payload.expiresAt } : {}),
+ });
+ renderRequests();
+ const inlineRequests = $("inlineRequests");
+ if (inlineRequests) inlineRequests.scrollTop = inlineRequests.scrollHeight;
+ finishAssistantStream();
+ // The inline request card is the canonical representation. A separate
+ // plain-text transcript line would duplicate the approval/input prompt
+ // and could be mistaken for Codex output.
+ const waitingActivity = payload.method === "item/tool/requestUserInput"
+ || payload.method === "mcpServer/elicitation/request"
+ ? "waiting_input"
+ : "waiting_approval";
+ state.currentActivity = waitingActivity;
+ setConversationStatus(statusActivityLabel(waitingActivity), "warning");
+ return;
+ }
+ if (event.type === "server.responded" || event.type === "approval.resolved" || event.type === "input.resolved" || event.type === "approval.expired" || event.type === "input.expired") {
+ const key = resolveRequestKey(payload.requestId);
+ state.responding.delete(key);
+ state.requests.delete(key);
+ renderRequests();
+ return;
+ }
+ if (event.type === "task.status") {
+ applyStatusSnapshot(payload, { allowTerminal: true, showIdle: false });
+ updateIds();
+ return;
+ }
+ if (event.type === "task.started") {
+ finishAssistantStream();
+ if (payload.turnId) state.turnId = payload.turnId;
+ if (payload.threadId) state.threadId = payload.threadId;
+ attachPendingUserToTurn(payload.turnId || state.turnId);
+ applyStatusSnapshot(payload, { allowTerminal: false });
+ if (!state.currentActivity || state.currentActivity === "idle") state.currentActivity = "running";
+ updateLiveActivity(state.currentActivity, state.turnStartedAt, null, payload.activeFlags || [], payload.turnId || state.turnId);
+ updateIds();
+ return;
+ }
+ if (event.type === "task.finished" || event.type === "task.cancelled") {
+ if (!eventBelongsToCurrentTurn(payload)) return;
+ finishAssistantStream();
+ const reportedStatus = payload.turnStatus
+ ?? payload.status?.turnStatus
+ ?? payload.status?.status
+ ?? payload.status
+ ?? payload.executionStatus?.turnStatus
+ ?? payload.executionStatus?.status
+ ?? payload.executionStatus;
+ const finalStatus = event.type === "task.cancelled"
+ ? "interrupted"
+ : normalizeActivityStatus(reportedStatus, "completed");
+ const finishedTurnId = eventTurnId(payload) || state.turnId || "";
+ applyStatusSnapshot({ ...payload, turnStatus: finalStatus }, { allowTerminal: false });
+ state.pendingUserText = "";
+ const worked = workedDurationFor(payload, null);
+ stopTurnClock(finalStatus, payload.durationMs, payload.completedAtMs, worked);
+ appendCompletedTurnDivider(finishedTurnId, finalStatus, worked ?? state.lastWorkedDurationMs ?? payload.durationMs ?? state.lastTurnDurationMs);
+ reconcileTurnDividers();
+ if (finishedTurnId) state.retiredTurnIds.add(finishedTurnId);
+ if (state.retiredTurnIds.size > 100) state.retiredTurnIds.delete(state.retiredTurnIds.values().next().value);
+ if (payload.threadId) state.threadId = payload.threadId;
+ state.pendingUserText = "";
+ state.turnId = "";
+ updateIds();
+ return;
+ }
+ if (event.type === "session.created" && payload.thread?.id) {
+ state.threadId = payload.thread.id;
+ if (state.syncedThreadId === null || state.syncedThreadId === payload.thread.id) {
+ prepareForSessionSnapshot(payload.thread.id);
+ applySessionMetadata({ ...(isRecord(payload.metadata) ? payload.metadata : {}), ...payload }, payload);
+ } else {
+ setConversationStatus("正在加载会话", "active");
+ }
+ updateIds();
+ return;
+ }
+ if (event.type === "session.snapshot") {
+ state.awaitingSnapshot = false;
+ state.appReady = true;
+ const snapshotThreadId = payload.threadId || "";
+ applyControlModeSnapshot(payload.metadata);
+ const waitingForSession = payload.state === "waiting_for_host"
+ || (isRecord(payload.metadata) && payload.metadata.waitingForSession === true);
+ const eventSnapshotSeq = finiteNumber(payload.latestSeq, event.seq);
+ if (eventSnapshotSeq !== null) state.lastSnapshotSeq = eventSnapshotSeq;
+ const adapterName = payload.metadata && payload.metadata.adapter;
+ if (adapterName === "codex-ipc-follower") setAttachMode(true);
+ else if (adapterName) setAttachMode(false);
+ const hasProjection = typeof payload.outputTail === "string" || Array.isArray(payload.messages);
+ const projectionCommitted = hasProjection && commitOutputProjection(
+ snapshotThreadId,
+ typeof payload.outputTail === "string" ? payload.outputTail : "",
+ payload.messages,
+ {
+ historyComplete: snapshotHistoryComplete(payload, payload.state),
+ authoritativeSnapshot: true,
+ },
+ );
+ const appliesToView = projectionCommitted || snapshotThreadId === state.syncedThreadId;
+ if (!appliesToView) {
+ // Keep the retained transcript and loading state until a matching,
+ // non-placeholder snapshot is available for the selected thread.
+ updateIds();
+ return;
+ }
+ prepareForSessionSnapshot(snapshotThreadId);
+ applySessionMetadata({ ...(isRecord(payload.metadata) ? payload.metadata : {}), ...payload }, payload.state);
+ if (Array.isArray(payload.subagents)) {
+ state.subagents = payload.subagents;
+ renderSubagents();
+ } else if (isRecord(payload.state) && Array.isArray(payload.state.subagents)) {
+ state.subagents = payload.state.subagents;
+ renderSubagents();
+ }
+ if (payload.threadId !== undefined) state.threadId = payload.threadId || "";
+ if (payload.turnId !== undefined) state.turnId = payload.turnId || "";
+ applyStatusSnapshot({
+ ...payload,
+ ...(payload.executionStatus ? { status: payload.executionStatus } : {}),
+ turnId: payload.turnId !== undefined ? payload.turnId : state.turnId,
+ }, { allowTerminal: true, showIdle: false });
+ if (payload.metadata && typeof payload.metadata.cwd === "string") $("cwdInput").value = payload.metadata.cwd;
+ reconcileSnapshotTerminalState(payload, payload.state);
+ if (Array.isArray(payload.pendingRequests)) {
+ state.requests = new Map(payload.pendingRequests
+ .filter((request) => request && request.requestId !== undefined)
+ .map((request) => [requestKey(request.requestId), request]));
+ state.responding.clear();
+ renderRequests();
+ }
+ if (waitingForSession) {
+ setConversationStatus("等待在 VS Code 中打开 Codex 会话", "active");
+ state.snapshotNoticeShown = false;
+ } else if (!state.snapshotNoticeShown && state.turnStartedAt === null && (!state.currentActivity || state.currentActivity === "idle")) {
+ setConversationStatus("ready");
+ state.snapshotNoticeShown = true;
+ }
+ updateIds();
+ return;
+ }
+ if (event.type === "session.closed") {
+ finishAssistantStream();
+ state.pendingUserText = "";
+ state.appReady = false;
+ state.sessions = [];
+ state.sessionFocusedId = "";
+ state.sessionSelectedThreadId = "";
+ state.threadId = "";
+ state.turnId = "";
+ const closedTitle = $("threadTitle");
+ if (closedTitle) closedTitle.textContent = "Codex";
+ state.turnStartedAt = null;
+ state.turnWorkStartedAt = null;
+ state.finalAssistantStartedAt = null;
+ state.workedDurationMs = null;
+ state.lastWorkedDurationMs = null;
+ state.currentActivity = "idle";
+ state.currentActivityStartedAt = null;
+ state.subagents = [];
+ resetSessionModelMetadata();
+ renderSubagents();
+ updateLiveActivity("idle");
+ state.sessionSwitching = false;
+ state.sessionSelectCommandId = "";
+ finishSessionSwitchContext();
+ setConversationStatus("会话已关闭", "warning");
+ state.sessionListError = "VS Code 会话已关闭";
+ renderSessionPicker();
+ updateIds();
+ return;
+ }
+ if (event.type === "app.notification") {
+ if (payload.method === "thread/started" && payload.params?.thread?.id) state.threadId = payload.params.thread.id;
+ if (payload.method === "turn/started" && payload.params?.turn?.id) {
+ state.turnId = payload.params.turn.id;
+ attachPendingUserToTurn(state.turnId);
+ }
+ if (payload.method === "turn/completed") {
+ state.turnId = "";
+ finishAssistantStream();
+ }
+ if (payload.text) {
+ finishAssistantStream();
+ appendOutput(payload.text);
+ }
+ else if (["thread/started", "turn/started", "turn/completed", "thread/status/changed"].includes(payload.method)) appendOutput(`${payload.method}`, "meta");
+ updateIds();
+ return;
+ }
+ if (event.type === "thread.started" && payload.params?.thread?.id) {
+ state.threadId = payload.params.thread.id;
+ updateIds();
+ return;
+ }
+ if (event.type === "turn.started" && payload.params?.turn?.id) {
+ state.turnId = payload.params.turn.id;
+ attachPendingUserToTurn(state.turnId);
+ updateIds();
+ return;
+ }
+ if (event.type === "turn.completed") {
+ finishAssistantStream();
+ const completedTurnId = eventTurnId(payload) || state.turnId || "";
+ const status = normalizeActivityStatus(payload.status, "completed");
+ const worked = workedDurationFor(payload, null);
+ stopTurnClock(status, payload.durationMs, payload.completedAtMs, worked);
+ appendCompletedTurnDivider(completedTurnId, status, worked ?? state.lastWorkedDurationMs ?? payload.durationMs ?? state.lastTurnDurationMs);
+ reconcileTurnDividers();
+ state.turnId = "";
+ updateIds();
+ return;
+ }
+ if (event.type === "command.result") {
+ const commandId = payload.commandId;
+ // The relay sends a sequenced event to every subscriber and a direct
+ // acknowledgement to the originating browser. Render a result once.
+ if (commandId) {
+ const key = String(commandId);
+ if (state.commandResults.has(key)) return;
+ state.commandResults.add(key);
+ if (state.commandResults.size > 2_000) state.commandResults.delete(state.commandResults.values().next().value);
+ }
+ const commandMethodName = sessionCommandMethod(payload.method);
+ if (commandMethodName === "control/mode/set") {
+ state.modeCommandId = "";
+ if (!payload.ok) {
+ clearControlModeRequest();
+ setConversationStatus(sessionErrorMessage(payload, "控制模式切换失败"), "warning");
+ } else if (state.modeSwitching) {
+ // The command result is only an acknowledgement. Keep the requested
+ // segment pending until a newer authoritative snapshot supplies the
+ // resulting modeEpoch and capabilities.
+ setConversationStatus("正在切换控制模式", "active");
+ }
+ updateIds();
+ return;
+ }
+ if (commandMethodName === "session/list" || commandMethodName === "thread/list") {
+ if (!payload.ok) {
+ state.sessionListLoading = false;
+ state.sessionListError = eventMessage(payload);
+ state.sessionListError = sessionErrorMessage(payload, state.sessionListError);
+ renderSessionPicker();
+ } else {
+ applySessionListResult(payload.result ?? payload);
+ }
+ return;
+ }
+ if (commandMethodName === "session/select" || commandMethodName === "thread/select") {
+ if (!payload.ok) {
+ state.sessionListError = failSessionSwitch(payload, eventMessage(payload));
+ renderSessionPicker();
+ setConversationStatus(state.sessionListError, "warning");
+ requestRefresh();
+ } else {
+ applySessionSelectResult(payload.result ?? payload);
+ }
+ return;
+ }
+ if (commandMethodName === "session/new" || commandMethodName === "thread/new") {
+ state.newSessionCommandId = "";
+ if (!payload.ok) {
+ const detail = eventMessage(payload);
+ setConversationStatus(
+ uiLocale() === "en-US"
+ ? `Unable to create a new conversation: ${detail}`
+ : `新会话创建失败:${detail}`,
+ "warning",
+ );
+ } else {
+ setConversationStatus("新会话已在 VS Code 中打开", "ready");
+ // The official command opens the new panel asynchronously. Give the
+ // host a short window to publish its rollout/owner, then refresh the
+ // same history menu so it can be selected without leaving the web UI.
+ openSessionHistory();
+ let refreshAttempts = 0;
+ const refreshNewSession = () => {
+ if (!state.sessionPickerOpen || refreshAttempts >= 4) return;
+ refreshAttempts += 1;
+ if (!state.sessionListLoading) requestSessionList();
+ setTimeout(refreshNewSession, 700);
+ };
+ setTimeout(refreshNewSession, 500);
+ }
+ updateIds();
+ return;
+ }
+ if (payload.method === "thread/settings/update") {
+ state.modelUpdatePending = false;
+ if (!payload.ok) {
+ const detail = eventMessage(payload);
+ appendOutput(
+ uiLocale() === "en-US"
+ ? `Unable to update model settings: ${detail}`
+ : `模型设置更新失败:${detail}`,
+ "error",
+ );
+ } else setConversationStatus(t("模型设置已更新"), "ready");
+ renderModelPicker();
+ return;
+ }
+ if (!payload.ok) {
+ const methodLabel = payload.method || t("命令");
+ const uncertainty = payload.uncertain
+ ? uiText("(执行状态未知,请等待主机恢复)", " (execution status unknown; wait for the host to recover)")
+ : "";
+ appendOutput(`${methodLabel}: ${JSON.stringify(payload.error)}${uncertainty}`, "error");
+ }
+ else {
+ const result = payload.result || {};
+ if (payload.method === "thread/start" && result.thread?.id) state.threadId = result.thread.id;
+ if (payload.method === "turn/start" && result.turn?.id) state.turnId = result.turn.id;
+ if (payload.method === "turn/start" && result.turn?.id) attachPendingUserToTurn(result.turn.id);
+ if (payload.method === "thread/start") {
+ applySessionMetadata({ ...result, ...(isRecord(result.thread) ? result.thread : {}) }, result);
+ }
+ finishAssistantStream();
+ appendOutput(`${payload.method || "命令"} 完成`, "meta");
+ updateIds();
+ }
+ }
+ }
+
+ function handleMessage(message) {
+ if (message.type === "auth.ok") {
+ state.role = message.role;
+ setAuthRequired(message.authRequired);
+ $("roleBadge").textContent = message.role;
+ $("roleBadge").className = `badge ${message.role === "operator" ? "" : "warning"}`;
+ setConnection("pending", "同步中");
+ state.awaitingSnapshot = true;
+ sendFrame({ type: "subscribe", fromSeq: state.lastSeq });
+ return;
+ }
+ // A host event can legitimately have the same type as a relay control
+ // frame (notably `session.snapshot`). Route the envelope by `kind` first
+ // so its payload is not mistaken for the compact control shape below.
+ if (message.kind === "event") {
+ handleEvent(message);
+ return;
+ }
+ if (message.type === "session.snapshot") {
+ state.awaitingSnapshot = false;
+ const snapshot = message.snapshot || {};
+ const appState = snapshot.state || {};
+ const snapshotThreadId = appState.activeThreadId || "";
+ const controlMetadata = {
+ ...(isRecord(snapshot.metadata) ? snapshot.metadata : {}),
+ ...(isRecord(appState.sessionMetadata) ? appState.sessionMetadata : {}),
+ };
+ applyControlModeSnapshot(controlMetadata);
+ const waitingForSession = controlMetadata.waitingForSession === true
+ || (!snapshotThreadId && controlMetadata.attachReady === false);
+ $("appState").textContent = appStatusLabel(appState.app);
+ $("factApp").textContent = appState.app ? appStatusLabel(appState.app) : "-";
+ $("factClients").textContent = String((snapshot.clients || []).length);
+ state.appReady = appState.app === "ready" || appState.initialized === true;
+ if (appState.mode === "host") setAttachMode(true);
+ if (appState.mode === "embedded") setAttachMode(false);
+ const snapshotSeq = finiteNumber(snapshot.latestSeq);
+ if (snapshotSeq !== null) {
+ state.lastSeq = snapshotSeq;
+ state.lastSnapshotSeq = snapshotSeq;
+ }
+ // The control snapshot is authoritative for routing, but its transcript
+ // fields can still be placeholders while VS Code is loading history.
+ // Route target events immediately without replacing the retained view.
+ if (snapshotThreadId || state.syncedThreadId === null) state.threadId = snapshotThreadId;
+ state.turnId = appState.activeTurnId || "";
+ state.requests = new Map((snapshot.pendingRequests || []).map((request) => [requestKey(request.requestId), request]));
+ state.responding.clear();
+ // `subscribe` replays buffered events before sending this control
+ // snapshot. The snapshot is authoritative, so reconcile once at the
+ // end of the replay instead of leaving transient duplicate bubbles.
+ const hasProjection = typeof snapshot.outputTail === "string" || Array.isArray(snapshot.messages);
+ const projectionCommitted = hasProjection && commitOutputProjection(
+ snapshotThreadId,
+ typeof snapshot.outputTail === "string" ? snapshot.outputTail : "",
+ snapshot.messages,
+ {
+ historyComplete: snapshotHistoryComplete(snapshot, snapshot.metadata, appState),
+ authoritativeSnapshot: true,
+ },
+ );
+ const appliesToView = projectionCommitted || snapshotThreadId === state.syncedThreadId;
+ if (appliesToView) {
+ prepareForSessionSnapshot(snapshotThreadId);
+ applySessionMetadata({
+ ...controlMetadata,
+ ...appState,
+ }, appState);
+ if (Array.isArray(snapshot.subagents)) {
+ state.subagents = snapshot.subagents;
+ renderSubagents();
+ } else if (Array.isArray(appState.subagents)) {
+ state.subagents = appState.subagents;
+ renderSubagents();
+ }
+ applyStatusSnapshot({
+ ...(snapshot.status ? { status: snapshot.status } : {}),
+ ...(snapshot.executionStatus ? { status: snapshot.executionStatus } : {}),
+ ...(snapshot.state && typeof snapshot.state === "object" ? snapshot.state : {}),
+ turnId: state.turnId,
+ }, { allowTerminal: true, showIdle: false });
+ reconcileSnapshotTerminalState(snapshot, appState);
+ } else if (state.sessionSwitching || hasVisibleOutputProjection()) {
+ setConversationStatus("正在加载会话", "active");
+ }
+ renderRequests();
+ updateIds();
+ setConnection("online", "已连接");
+ if (waitingForSession) {
+ setConversationStatus("等待在 VS Code 中打开 Codex 会话", "active");
+ state.snapshotNoticeShown = false;
+ } else {
+ $("outputHint").textContent = `${appStatusLabel(appState.app || "app-server")} / ${state.role}`;
+ }
+ if (state.sessionPickerOpen && state.appReady) requestSessionList();
+ return;
+ }
+ if (message.type === "resync.required") {
+ appendOutput("事件窗口已过期,请以当前快照为准", "error");
+ return;
+ }
+ if (message.type === "response.accepted") {
+ const key = resolveRequestKey(message.requestId);
+ state.responding.delete(key);
+ state.requests.delete(key);
+ renderRequests();
+ appendOutput(`请求 #${message.requestId} 已提交`, "meta");
+ return;
+ }
+ if (message.type === "response.pending") {
+ appendOutput(`请求 #${message.requestId} 已发送,等待 VS Code 主机确认`, "meta");
+ return;
+ }
+ if (message.type === "command.accepted") return;
+ if (message.type === "command.rejected" || message.type === "response.rejected") {
+ if (message.requestId !== undefined) state.responding.delete(resolveRequestKey(message.requestId));
+ const rejectedId = String(message.commandId || "");
+ if (rejectedId && rejectedId === String(state.modeCommandId || "")) {
+ clearControlModeRequest();
+ setConversationStatus(sessionErrorMessage(message, "控制模式切换失败"), "warning");
+ updateIds();
+ return;
+ }
+ if (rejectedId && rejectedId === String(state.newSessionCommandId || "")) {
+ state.newSessionCommandId = "";
+ const detail = message.message || message.code || t("未知错误");
+ setConversationStatus(
+ uiLocale() === "en-US"
+ ? `Unable to create a new conversation: ${detail}`
+ : `新会话创建失败:${detail}`,
+ "warning",
+ );
+ updateIds();
+ return;
+ }
+ if (rejectedId && rejectedId === String(state.sessionListCommandId || "")) {
+ state.sessionListLoading = false;
+ state.sessionListCommandId = "";
+ state.sessionListError = sessionErrorMessage(message, "无法读取会话");
+ renderSessionPicker();
+ return;
+ }
+ if (rejectedId && rejectedId === String(state.sessionSelectCommandId || "")) {
+ state.sessionListError = failSessionSwitch(message, "会话切换失败");
+ renderSessionPicker();
+ setConversationStatus(state.sessionListError, "warning");
+ requestRefresh();
+ return;
+ }
+ appendOutput(`${message.code}: ${message.message}`, "error");
+ renderRequests();
+ return;
+ }
+ if (message.type === "command.result") {
+ handleEvent({ type: "command.result", seq: message.seq, payload: message });
+ return;
+ }
+ if (message.type === "error") appendOutput(message.message || "relay error", "error");
+ }
+
+ function embeddedSocketUrl(value) {
+ if (!embeddedInAether) return "";
+ const raw = String(value || "").trim();
+ if (!raw) return "";
+ try {
+ const url = new URL(raw, location.href);
+ const expectedProtocol = location.protocol === "https:" ? "wss:" : "ws:";
+ if (url.origin !== `${location.protocol}//${location.host}` && url.origin !== `${expectedProtocol}//${location.host}`) {
+ throw new Error(t("云端连接地址必须与当前页面同源"));
+ }
+ if (url.protocol === "http:") url.protocol = "ws:";
+ if (url.protocol === "https:") url.protocol = "wss:";
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new Error(t("云端连接配置无效"));
+ return url.href;
+ } catch (error) {
+ appendOutput(error?.message || t("云端连接配置无效"), "error");
+ embedBridge.reportState("error", { code: "invalid_ws_url", message: error?.message || "invalid ws url" });
+ return "";
+ }
+ }
+
+ function requestEmbedTicket(reason = "missing") {
+ if (!embeddedInAether || state.embedStopped || state.embedTicketRequested) return;
+ state.embedTicketRequested = true;
+ setConversationStatus(t("正在获取新的连接凭证"), "active");
+ embedBridge.requestTicket({ reason, deviceId: state.embedDeviceId || undefined });
+ }
+
+ function disconnectEmbedded(reason = "parent") {
+ if (!embeddedInAether) return;
+ state.embedStopped = true;
+ state.embedTicket = "";
+ state.embedTicketRequested = false;
+ clearTimeout(state.reconnectTimer);
+ state.reconnectTimer = null;
+ const socket = state.ws;
+ state.ws = null;
+ if (socket && socket.readyState <= WebSocket.OPEN) socket.close(1000, reason);
+ setConnection("offline", "云端连接已断开");
+ setConversationStatus(t("父页面已断开连接"), "warning");
+ }
+
+ function applyEmbedConnection(message) {
+ if (!embeddedInAether) return;
+ if (message.locale) i18n?.setLocale?.(message.locale, { persist: false });
+ const ticket = typeof message.ticket === "string" ? message.ticket.trim() : "";
+ const wsUrl = embeddedSocketUrl(message.wsUrl || "/api/vscodex/ws");
+ if (!ticket || !wsUrl) {
+ appendOutput(t("云端连接配置无效"), "error");
+ embedBridge.reportState("error", { code: "invalid_connection_config", message: "ticket and wsUrl are required" });
+ requestEmbedTicket("invalid");
+ return;
+ }
+ state.embedStopped = false;
+ state.embedTicketRequested = false;
+ state.embedTicket = ticket;
+ state.embedWsUrl = wsUrl;
+ state.embedDeviceId = typeof message.deviceId === "string" ? message.deviceId : state.embedDeviceId;
+ setAuthRequired(true);
+ document.body.classList.add("embed-aether");
+ setConversationStatus(t("正在连接云端会话"), "active");
+ if (state.ws && state.ws.readyState <= WebSocket.OPEN) {
+ const socket = state.ws;
+ state.ws = null;
+ socket.close(1000, "connection replaced");
+ }
+ connect();
+ }
+
+ function connect() {
+ if (state.ws && state.ws.readyState <= WebSocket.OPEN) return;
+ if (embeddedInAether) {
+ if (state.embedStopped) return;
+ if (!state.embedTicket || !state.embedWsUrl) { requestEmbedTicket("missing"); return; }
+ state.token = state.embedTicket;
+ } else state.token = $("tokenInput").value.trim();
+ if (!state.token && state.authRequired === true) { appendOutput("当前 relay 需要 token", "error"); return; }
+ setConnection("pending", "连接中");
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
+ let socket;
+ try {
+ socket = new WebSocket(embeddedInAether ? state.embedWsUrl : `${protocol}//${location.host}/ws`);
+ } catch (error) {
+ if (embeddedInAether) {
+ state.embedTicket = "";
+ requestEmbedTicket("socket-error");
+ }
+ appendOutput(error?.message || "WebSocket 未连接", "error");
+ return;
+ }
+ const connectionTicket = embeddedInAether ? state.embedTicket : "";
+ if (embeddedInAether) {
+ state.embedTicket = "";
+ state.token = "";
+ }
+ state.ws = socket;
+ socket.addEventListener("open", () => {
+ if (state.ws !== socket) return;
+ // Send a hello even when local auth is disabled so the relay can assign
+ // the browser role without requiring a dummy password.
+ socket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 }));
+ // A loopback relay authenticates on hello. Do not send a stale token as
+ // a second frame after that handshake, because it is already complete.
+ const token = embeddedInAether ? connectionTicket : state.token;
+ if (token && state.authRequired !== false) socket.send(JSON.stringify({ type: "auth", token }));
+ });
+ socket.addEventListener("message", (event) => {
+ if (state.ws !== socket) return;
+ try { handleMessage(JSON.parse(event.data)); } catch { appendOutput("收到无法解析的 relay 消息", "error"); }
+ });
+ socket.addEventListener("close", (event) => {
+ if (state.ws !== socket) return;
+ setConnection("offline", event.code === 1008 ? "认证失败,准备重连" : "准备重连");
+ state.appReady = false;
+ state.turnId = "";
+ state.sessionListLoading = false;
+ state.sessionListCommandId = "";
+ state.newSessionCommandId = "";
+ clearControlModeRequest();
+ state.sessions = [];
+ state.sessionFocusedId = "";
+ // A transport reconnect may resume the same owner hand-off. Preserve
+ // its previous/target identities so the next control placeholder cannot
+ // be mistaken for an authoritative empty target transcript.
+ if (!state.sessionSwitching) state.sessionSelectedThreadId = "";
+ state.sessionListError = "等待 relay 连接";
+ updateIds();
+ state.ws = null;
+ renderSessionPicker();
+ clearTimeout(state.reconnectTimer);
+ state.reconnectTimer = null;
+ if (embeddedInAether) {
+ if (!state.embedStopped) requestEmbedTicket(event.code === 1008 ? "ticket-rejected" : "disconnected");
+ } else state.reconnectTimer = setTimeout(connect, 3000);
+ });
+ socket.addEventListener("error", () => {
+ // The close handler owns retrying. Keep transient socket errors in the
+ // connection indicator instead of adding noisy messages to the turn.
+ if (state.ws === socket) setConnection("offline", "重连中");
+ });
+ }
+
+ function closePopovers() {
+ for (const id of ["panelMenu", "detailsPopover", "sessionPicker", "composerPlusMenu"]) {
+ const element = $(id);
+ if (element) element.hidden = true;
+ }
+ state.sessionPickerOpen = false;
+ state.sessionSearch = "";
+ state.sessionFocusedId = "";
+ $("sessionPickerButton")?.setAttribute("aria-expanded", "false");
+ const sessionSearchInput = $("sessionSearchInput");
+ if (sessionSearchInput) {
+ sessionSearchInput.value = "";
+ sessionSearchInput.setAttribute("aria-expanded", "false");
+ sessionSearchInput.setAttribute("aria-activedescendant", "");
+ }
+ $("sessionSearchClear")?.setAttribute("hidden", "");
+ setModelMenu(false);
+ setPermissionMenu(false);
+ setUsageMenu(false);
+ $("composerPlusButton")?.setAttribute("aria-expanded", "false");
+ const confirm = $("permissionConfirm");
+ if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; }
+ }
+
+ function requestRefresh() {
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
+ try { sendFrame({ type: "subscribe", fromSeq: state.lastSeq }); } catch { connect(); }
+ } else connect();
+ }
+
+ document.querySelectorAll("[data-panel-action]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const action = button.dataset.panelAction;
+ if (action === "back" || action === "history") {
+ setSessionPicker(!state.sessionPickerOpen);
+ return;
+ }
+ if (action === "new-session") {
+ requestNewSession();
+ return;
+ }
+ if (action === "expand") {
+ document.body.classList.toggle("panel-expanded");
+ return;
+ }
+ if (action === "close") {
+ document.body.classList.add("panel-hidden");
+ const restore = $("restorePanel");
+ if (restore) restore.hidden = false;
+ closePopovers();
+ return;
+ }
+ if (action === "refresh") { closePopovers(); requestRefresh(); return; }
+ if (action === "menu") {
+ const menu = $("panelMenu");
+ const details = $("detailsPopover");
+ if (details) details.hidden = true;
+ if (menu) menu.hidden = !menu.hidden;
+ return;
+ }
+ if (action === "settings") {
+ const details = $("detailsPopover");
+ const menu = $("panelMenu");
+ if (menu) menu.hidden = true;
+ if (details) details.hidden = !details.hidden;
+ updateIds();
+ }
+ });
+ });
+ $("sessionPickerButton")?.addEventListener("click", () => {
+ setSessionPicker(!state.sessionPickerOpen);
+ });
+ $("controlModeSwitch")?.querySelectorAll("[data-control-mode]").forEach((button) => {
+ button.addEventListener("click", () => requestControlMode(button.dataset.controlMode));
+ });
+ $("sessionPickerRefresh")?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ requestSessionList();
+ });
+ $("sessionSearchInput")?.addEventListener("input", (event) => {
+ const input = event.currentTarget;
+ if (!(input instanceof HTMLInputElement)) return;
+ state.sessionSearch = input.value;
+ state.sessionFocusedId = "";
+ renderSessionPicker();
+ });
+ $("sessionSearchInput")?.addEventListener("keydown", handleSessionPickerKeydown);
+ $("sessionList")?.addEventListener("keydown", handleSessionPickerKeydown);
+ $("sessionSearchClear")?.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ state.sessionSearch = "";
+ state.sessionFocusedId = "";
+ renderSessionPicker();
+ $("sessionSearchInput")?.focus();
+ });
+ $("restorePanel")?.addEventListener("click", () => {
+ document.body.classList.remove("panel-hidden");
+ $("restorePanel").hidden = true;
+ });
+ $("panelMenu")?.querySelectorAll("[data-menu-action]").forEach((button) => {
+ button.addEventListener("click", (event) => {
+ if (button.dataset.menuAction === "sessions") {
+ // The document-level outside-click handler runs in the same bubble
+ // phase. Keep the picker open when it is launched from this menu.
+ event.stopPropagation();
+ closePopovers();
+ setSessionPicker(true);
+ return;
+ } else if (button.dataset.menuAction === "clear") {
+ renderEmptyOutput();
+ state.outputSynced = false;
+ } else if (button.dataset.menuAction === "refresh") requestRefresh();
+ else if (button.dataset.menuAction === "expand") document.body.classList.toggle("panel-expanded");
+ else if (button.dataset.menuAction === "close") {
+ document.body.classList.add("panel-hidden");
+ const restore = $("restorePanel");
+ if (restore) restore.hidden = false;
+ }
+ closePopovers();
+ });
+ });
+ $("detailsPopover")?.querySelectorAll("[data-settings-action]").forEach((button) => {
+ button.addEventListener("click", (event) => {
+ event.stopPropagation();
+ const action = button.dataset.settingsAction;
+ closePopovers();
+ if (action === "model") {
+ setModelMenu(true);
+ $("modelPickerButton")?.focus();
+ } else if (action === "permission") {
+ setPermissionMenu(true);
+ $("permissionChip")?.focus();
+ }
+ });
+ });
+ document.addEventListener("click", (event) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ if (!target.closest(".model-picker")) setModelMenu(false);
+ if (!target.closest(".permission-menu, #permissionChip")) setPermissionMenu(false);
+ if (!target.closest(".usage-menu, #usageButton")) setUsageMenu(false);
+ if (!target.closest(".composer-plus-menu, #composerPlusButton")) {
+ const menu = $("composerPlusMenu");
+ if (menu) menu.hidden = true;
+ $("composerPlusButton")?.setAttribute("aria-expanded", "false");
+ }
+ if (!target.closest("[data-panel-action], .panel-popover, .composer-popover, .composer-icon-button, .permission-chip, .usage-button, #sessionPickerButton")) closePopovers();
+ });
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && state.sessionPickerOpen) {
+ event.preventDefault();
+ closePopovers();
+ $("sessionPickerButton")?.focus();
+ }
+ });
+
+ $("tokenInput").addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ connect();
+ }
+ });
+ $("tokenInput").addEventListener("change", connect);
+ $("localeSelect")?.addEventListener("change", (event) => {
+ if (!embeddedInAether) i18n?.setLocale?.(event.target.value, { persist: true });
+ });
+ window.addEventListener("aether-vscodex:locale", () => {
+ for (const activity of state.activities.values()) {
+ renderActivityText(activity);
+ refreshActivity(activity);
+ }
+ for (const [turnId, divider] of state.turnDividers) {
+ const label = divider.querySelector(".turn-divider-label");
+ if (!label) continue;
+ const duration = finiteNumber(divider.dataset.durationMs);
+ label.textContent = turnDividerLabel(divider.dataset.status || "completed", duration);
+ }
+ setAttachMode(state.attachMode);
+ setAuthRequired(state.authRequired);
+ renderSessionPicker();
+ renderModelPicker();
+ renderPermissionMenu();
+ renderUsage();
+ renderSubagents();
+ updateIds();
+ renderRequests();
+ for (const checkbox of document.querySelectorAll(".task-list-item input[type=checkbox]")) {
+ checkbox.setAttribute("aria-label", t(checkbox.checked ? "已完成" : "未完成"));
+ }
+ });
+ $("clearOutputButton").addEventListener("click", () => {
+ renderEmptyOutput();
+ state.outputSynced = false;
+ });
+ $("startThreadButton").addEventListener("click", () => {
+ if (state.attachMode) return;
+ const params = { cwd: $("cwdInput").value.trim() || undefined, sandbox: $("sandboxInput").value, approvalPolicy: $("approvalInput").value };
+ if ($("modelInput").value.trim()) params.model = $("modelInput").value.trim();
+ command("thread/start", params);
+ });
+ $("startTurnButton").addEventListener("click", () => {
+ const text = composerText();
+ if (!text || !state.threadId) return;
+ command("turn/start", {
+ threadId: state.threadId,
+ input: [{ type: "text", text, text_elements: [] }],
+ ...(state.currentModel ? { model: state.currentModel } : {}),
+ ...currentEffortParams(),
+ });
+ clearComposer();
+ });
+ $("steerButton").addEventListener("click", () => {
+ const text = composerText();
+ if (!text || !state.threadId || !state.turnId) return;
+ command("turn/steer", {
+ threadId: state.threadId,
+ expectedTurnId: state.turnId,
+ input: [{ type: "text", text, text_elements: [] }],
+ ...(state.currentModel ? { model: state.currentModel } : {}),
+ ...currentEffortParams(),
+ });
+ clearComposer();
+ });
+ $("interruptButton").addEventListener("click", () => {
+ if (state.threadId && state.turnId) command("turn/interrupt", { threadId: state.threadId, turnId: state.turnId });
+ });
+ $("messageInput").addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
+ event.preventDefault();
+ const button = state.turnId ? $("steerButton") : $("startTurnButton");
+ if (button && !button.disabled) button.click();
+ });
+ $("messageInput").addEventListener("input", () => {
+ resizeComposer();
+ updateIds();
+ });
+ $("messageInput").addEventListener("paste", (event) => {
+ event.preventDefault();
+ const text = event.clipboardData?.getData("text/plain") || "";
+ if (!text) return;
+ const editor = $("messageInput");
+ const selection = window.getSelection();
+ if (editor && selection && selection.rangeCount) {
+ const range = selection.getRangeAt(0);
+ if (editor.contains(range.commonAncestorContainer)) {
+ range.deleteContents();
+ const node = document.createTextNode(text);
+ range.insertNode(node);
+ range.setStartAfter(node);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ } else editor.append(document.createTextNode(text));
+ } else editor?.append(document.createTextNode(text));
+ resizeComposer();
+ updateIds();
+ });
+ $("modelPickerButton")?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ const menu = $("modelMenu");
+ setPermissionMenu(false);
+ setUsageMenu(false);
+ const plus = $("composerPlusMenu");
+ if (plus) plus.hidden = true;
+ $("composerPlusButton")?.setAttribute("aria-expanded", "false");
+ setModelMenu(Boolean(menu?.hidden));
+ });
+ $("modelAdvancedToggle")?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ state.modelAdvancedOpen = !state.modelAdvancedOpen;
+ renderModelPicker();
+ if (state.modelAdvancedOpen) $("modelAdvancedBack")?.focus();
+ else $("modelPowerSlider")?.focus();
+ });
+ $("modelAdvancedBack")?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ state.modelAdvancedOpen = false;
+ renderModelPicker();
+ $("modelPowerSlider")?.focus();
+ });
+ $("modelPowerSlider")?.addEventListener("input", (event) => {
+ selectPowerIndex(event.currentTarget.value);
+ });
+ $("composerPlusButton")?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ const menu = $("composerPlusMenu");
+ if (!menu) return;
+ const next = menu.hidden;
+ menu.hidden = !next;
+ $("composerPlusButton").setAttribute("aria-expanded", String(next));
+ if (next) {
+ setPermissionMenu(false);
+ setUsageMenu(false);
+ setModelMenu(false);
+ }
+ });
+ $("permissionChip")?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ const menu = $("permissionMenu");
+ setPermissionMenu(Boolean(menu?.hidden));
+ if (!menu?.hidden) {
+ const plus = $("composerPlusMenu");
+ if (plus) plus.hidden = true;
+ $("composerPlusButton")?.setAttribute("aria-expanded", "false");
+ setUsageMenu(false);
+ setModelMenu(false);
+ }
+ });
+ $("usageButton")?.addEventListener("click", (event) => {
+ event.stopPropagation();
+ const menu = $("usageMenu");
+ setPermissionMenu(false);
+ setModelMenu(false);
+ const plus = $("composerPlusMenu");
+ if (plus) plus.hidden = true;
+ $("composerPlusButton")?.setAttribute("aria-expanded", "false");
+ setUsageMenu(Boolean(menu?.hidden));
+ if (!menu?.hidden) {
+ setPermissionMenu(false);
+ setModelMenu(false);
+ }
+ });
+ const insertComposerText = (text) => {
+ const editor = $("messageInput");
+ if (!editor || !text) return;
+ editor.focus();
+ const selection = window.getSelection();
+ if (selection && selection.rangeCount) {
+ const range = selection.getRangeAt(0);
+ if (editor.contains(range.commonAncestorContainer)) {
+ range.deleteContents();
+ const node = document.createTextNode(text);
+ range.insertNode(node);
+ range.setStartAfter(node);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ } else editor.append(document.createTextNode(text));
+ } else editor.append(document.createTextNode(text));
+ resizeComposer();
+ updateIds();
+ };
+ $("composerPlusMenu")?.querySelectorAll("[data-composer-action]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const action = button.dataset.composerAction;
+ if (action === "attach") {
+ const input = $("attachmentInput");
+ if (input) { input.accept = ".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h"; input.click(); }
+ } else if (action === "photo") {
+ const input = $("attachmentInput");
+ if (input) { input.accept = "image/*"; input.click(); }
+ } else if (action === "workspace") {
+ insertComposerText("\n\n@workspace ");
+ setConversationStatus("已添加工作区上下文", "ready");
+ } else if (action === "web-search") {
+ insertComposerText("\n\n/web-search ");
+ setConversationStatus("已添加网页搜索", "ready");
+ }
+ const menu = $("composerPlusMenu");
+ if (menu) menu.hidden = true;
+ $("composerPlusButton")?.setAttribute("aria-expanded", "false");
+ });
+ });
+ $("attachmentInput")?.addEventListener("change", async (event) => {
+ const files = [...(event.target?.files || [])];
+ for (const file of files) {
+ try {
+ if (file.type.startsWith("image/")) {
+ insertComposerText(`\n\n[图片附件:${file.name}]\n`);
+ continue;
+ }
+ const text = await file.text();
+ const clipped = text.length > 80_000 ? `${text.slice(0, 80_000)}\n${t("…(文件已截断)")}` : text;
+ const fence = "```";
+ insertComposerText(`\n\n### ${file.name}\n\n${fence}\n${clipped}\n${fence}\n`);
+ } catch {
+ setConversationStatus(`无法读取 ${file.name}`, "warning");
+ }
+ }
+ event.target.value = "";
+ });
+ $("permissionMenu")?.querySelectorAll("[data-permission-mode], [data-sandbox], [data-approval]").forEach((button) => {
+ button.addEventListener("click", () => {
+ if (button.dataset.permissionMode) selectPermissionMode(button.dataset.permissionMode);
+ else if (button.dataset.sandbox) selectPermissionSetting("sandbox", button.dataset.sandbox);
+ else if (button.dataset.approval) selectPermissionSetting("approval", button.dataset.approval);
+ });
+ });
+ $("permissionConfirmCancel")?.addEventListener("click", () => {
+ const confirm = $("permissionConfirm");
+ if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; }
+ });
+ $("permissionConfirmAccept")?.addEventListener("click", () => {
+ const confirm = $("permissionConfirm");
+ const mode = confirm?.dataset.pendingMode || "full";
+ if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; }
+ applyPermissionMode(mode);
+ });
+ $("subagentsToggle")?.addEventListener("click", () => {
+ state.subagentsCollapsed = !state.subagentsCollapsed;
+ renderSubagents();
+ });
+ $("output").addEventListener("scroll", () => updateScrollToBottom($("output")), { passive: true });
+ $("scrollToBottom")?.addEventListener("click", () => {
+ const output = $("output");
+ if (output) output.scrollTo({ top: output.scrollHeight, behavior: "smooth" });
+ });
+ if (typeof ResizeObserver === "function") {
+ const output = $("output");
+ const observedContent = new Set();
+ const observeTranscriptContent = () => {
+ const children = new Set(output ? [...output.children] : []);
+ for (const child of observedContent) {
+ if (children.has(child)) continue;
+ layoutObserver.unobserve(child);
+ observedContent.delete(child);
+ }
+ for (const child of children) {
+ if (observedContent.has(child)) continue;
+ observedContent.add(child);
+ layoutObserver.observe(child);
+ }
+ };
+ const layoutObserver = new ResizeObserver(() => {
+ const distance = state.outputDistanceFromBottom;
+ const following = distance <= 24;
+ const anchorLocked = motionClock() < (state.timelineAnchorLockUntil || 0);
+ updateScrollPadding();
+ // Preserve the reader's distance from the bottom when a streamed item or
+ // the composer changes height. The official thread layout uses the same
+ // bottom-relative anchor instead of allowing content to jump.
+ if (output && !anchorLocked) {
+ if (following) scrollOutput(output, true);
+ else output.scrollTop = Math.max(0, output.scrollHeight - output.clientHeight - distance);
+ updateScrollToBottom(output);
+ } else if (output) updateScrollToBottom(output);
+ observeTranscriptContent();
+ });
+ layoutObserver.observe($("messageForm"));
+ layoutObserver.observe($("inlineRequests"));
+ if (output) layoutObserver.observe(output);
+ observeTranscriptContent();
+ if (typeof MutationObserver === "function" && output) {
+ const childObserver = new MutationObserver(observeTranscriptContent);
+ childObserver.observe(output, { childList: true });
+ }
+ }
+ window.addEventListener("resize", updateScrollPadding, { passive: true });
+ $("cwdInput").value = location.pathname === "/" ? "" : "";
+ renderPermissionMenu();
+ renderUsage();
+ renderSessionPicker();
+ resizeComposer();
+ updateScrollPadding();
+ updateScrollToBottom($("output"));
+ updateIds();
+
+ if (embeddedInAether) {
+ document.body.classList.add("embed-aether");
+ setAuthRequired(true);
+ setConversationStatus(t("正在等待云端连接"), "active");
+ embedBridge.on("connect", applyEmbedConnection);
+ embedBridge.on("context", (message) => {
+ if (message.locale) i18n?.setLocale?.(message.locale, { persist: false });
+ });
+ embedBridge.on("disconnect", () => disconnectEmbedded("parent disconnect"));
+ embedBridge.on("error", (message) => {
+ const detail = typeof message.message === "string" && message.message ? message.message : t("云端连接已断开");
+ appendOutput(detail, "error");
+ setConversationStatus(detail, "warning");
+ });
+ } else {
+ // /api/health is intentionally public and only reports capability metadata.
+ // Probe it first so a local relay can connect automatically without a token;
+ // Authenticated deployments wait until a token is entered in the field.
+ fetch("./api/health", { cache: "no-store" }).then(async (response) => {
+ let health;
+ try { health = await response.json(); } catch { return; }
+ setAuthRequired(health.authRequired);
+ if (health.authRequired === false || (health.authRequired === true && $("tokenInput").value.trim())) connect();
+ }).catch(() => undefined);
+ }
+})();
diff --git a/aether-vscodex/public/embed-bridge.js b/aether-vscodex/public/embed-bridge.js
new file mode 100644
index 000000000..0d0a7416b
--- /dev/null
+++ b/aether-vscodex/public/embed-bridge.js
@@ -0,0 +1,112 @@
+(function (root, factory) {
+ "use strict";
+
+ const api = factory();
+ if (typeof module === "object" && module.exports) module.exports = api;
+ if (!root || !root.document) return;
+
+ const bridge = api.createAetherEmbedBridge(root);
+ root.AetherVscodexEmbed = bridge;
+ if (bridge.active) bridge.start();
+})(typeof window === "object" ? window : undefined, function () {
+ "use strict";
+
+ const VERSION = 1;
+ const PREFIX = "aether-vscodex/";
+ const INBOUND_TYPES = new Set(["connect", "context", "disconnect", "error"]);
+
+ function isAetherEmbed(locationLike) {
+ try {
+ return new URLSearchParams(locationLike?.search || "").get("embed") === "aether";
+ } catch {
+ return false;
+ }
+ }
+
+ function normalizeTheme(value) {
+ const theme = String(value || "").trim().toLowerCase();
+ return theme === "dark" || theme === "light" ? theme : "system";
+ }
+
+ function createAetherEmbedBridge(windowLike) {
+ const active = isAetherEmbed(windowLike.location);
+ const listeners = new Map();
+ const pending = new Map();
+ let started = false;
+
+ const emit = (name, payload) => {
+ for (const listener of listeners.get(name) || []) listener(payload);
+ };
+
+ const post = (type, payload = {}) => {
+ if (!active || windowLike.parent === windowLike) return false;
+ windowLike.parent.postMessage({ v: VERSION, type: `${PREFIX}${type}`, ...payload }, windowLike.location.origin);
+ return true;
+ };
+
+ const applyContext = (payload) => {
+ if (payload.locale && windowLike.VscodexI18n?.setLocale) {
+ windowLike.VscodexI18n.setLocale(payload.locale, { persist: false });
+ }
+ const theme = normalizeTheme(payload.theme);
+ const documentElement = windowLike.document?.documentElement;
+ if (documentElement) {
+ if (theme === "system") delete documentElement.dataset.theme;
+ else documentElement.dataset.theme = theme;
+ documentElement.style.colorScheme = theme === "system" ? "" : theme;
+ }
+ };
+
+ const handleMessage = (event) => {
+ if (!active || event.origin !== windowLike.location.origin || event.source !== windowLike.parent) return;
+ const message = event.data;
+ if (!message || typeof message !== "object" || message.v !== VERSION || typeof message.type !== "string") return;
+ if (!message.type.startsWith(PREFIX)) return;
+ const name = message.type.slice(PREFIX.length);
+ if (!INBOUND_TYPES.has(name)) return;
+ if (name === "connect" || name === "context") applyContext(message);
+ if (!(listeners.get(name)?.size)) pending.set(name, message);
+ emit(name, message);
+ };
+
+ return {
+ active,
+ version: VERSION,
+ start() {
+ if (!active || started) return;
+ started = true;
+ windowLike.document.body?.classList.add("embed-aether");
+ windowLike.addEventListener("message", handleMessage);
+ post("ready");
+ },
+ stop() {
+ if (!started) return;
+ started = false;
+ windowLike.removeEventListener("message", handleMessage);
+ listeners.clear();
+ pending.clear();
+ },
+ on(name, listener) {
+ if (!INBOUND_TYPES.has(name) || typeof listener !== "function") return () => undefined;
+ if (!listeners.has(name)) listeners.set(name, new Set());
+ listeners.get(name).add(listener);
+ if (pending.has(name)) {
+ const message = pending.get(name);
+ pending.delete(name);
+ listener(message);
+ }
+ return () => listeners.get(name)?.delete(listener);
+ },
+ post,
+ requestTicket(payload = {}) {
+ return post("request-ticket", payload);
+ },
+ reportState(state, payload = {}) {
+ return post("state", { state, ...payload });
+ },
+ _handleMessage: handleMessage,
+ };
+ }
+
+ return { createAetherEmbedBridge, isAetherEmbed, normalizeTheme };
+});
diff --git a/aether-vscodex/public/i18n.js b/aether-vscodex/public/i18n.js
new file mode 100644
index 000000000..8c16b8f3b
--- /dev/null
+++ b/aether-vscodex/public/i18n.js
@@ -0,0 +1,541 @@
+(function (root, factory) {
+ "use strict";
+
+ const api = factory(root);
+ if (typeof module === "object" && module.exports) module.exports = api;
+ if (root?.document) root.VscodexI18n = api;
+})(typeof window === "object" ? window : undefined, function (root) {
+ "use strict";
+
+ const STORAGE_KEY = "aether-vscodex.locale";
+ const SUPPORTED = new Set(["zh-CN", "en-US"]);
+ const EN = Object.freeze({
+ "本地模式": "Local mode",
+ "独立模式": "Standalone mode",
+ "云端模式": "Cloud mode",
+ "控制模式": "Control mode",
+ "同步": "Sync",
+ "异步": "Async",
+ "同步模式跟随 VS Code 当前会话": "Sync mode follows the current VS Code conversation",
+ "异步模式可独立管理会话": "Async mode manages conversations independently",
+ "正在切换控制模式": "Switching control mode",
+ "控制模式已切换": "Control mode switched",
+ "控制模式切换失败": "Unable to switch control mode",
+ "当前任务或请求完成后才能切换控制模式": "The control mode can be changed after the current task or request finishes",
+ "同步模式下会话管理由 VS Code 控制": "VS Code controls conversation navigation in sync mode",
+ "当前模式不支持修改会话设置": "The current mode does not support changing conversation settings",
+ "本机连接(无需 token)": "Local connection (no token required)",
+ "本机模式无需填写;认证模式再填写": "No token is needed locally; enter one only for authenticated mode",
+ "访问 token(认证模式)": "Access token (authenticated mode)",
+ "粘贴 relay 启动时打印的 token": "Paste the token printed when the relay started",
+ "本地连接无需 token": "No token is needed for a local connection",
+ "编辑外部文件和联网时始终询问": "Always ask before editing external files or using the network",
+ "不限制联网或文件访问": "Allow unrestricted network and file access",
+ "查看请求数据": "View request data",
+ "查看上下文用量": "View context usage",
+ "创建新会话": "New conversation",
+ "打开会话历史": "Open conversation history",
+ "待处理的 Codex 请求": "Pending Codex requests",
+ "当前会话": "Current conversation",
+ "当前模型": "Current model",
+ "切换模型": "Change model",
+ "等待 VS Code 主机": "Waiting for VS Code host",
+ "等待连接": "Waiting for connection",
+ "对话内容": "Conversation",
+ "发送 JSON": "Send JSON",
+ "发送后续指令": "Send follow-up",
+ "发送消息": "Send message",
+ "返回会话列表": "Back to conversations",
+ "返回模型强度": "Back to model effort",
+ "高级": "Advanced",
+ "简洁": "Simple",
+ "更多操作": "More actions",
+ "更高效": "More efficient",
+ "更智能": "More capable",
+ "工作目录": "Working directory",
+ "工作区": "Workspace",
+ "工作区写入": "Workspace write",
+ "回到最新消息": "Jump to latest message",
+ "正在工作,回到最新消息": "Working, jump to latest message",
+ "会话历史": "Conversation history",
+ "会话设置": "Conversation settings",
+ "仅本次 turn": "This turn only",
+ "仅查看文件,不修改工作区": "View files without changing the workspace",
+ "仅对可能不安全的操作询问": "Ask only for potentially unsafe actions",
+ "拒绝": "Deny",
+ "可用会话": "Available conversations",
+ "连接设置": "Connection settings",
+ "留空使用默认模型": "Leave empty to use the default model",
+ "模式": "Mode",
+ "模型": "Model",
+ "模型与推理强度": "Model and reasoning effort",
+ "默认": "Default",
+ "启动新 thread": "Start new thread",
+ "强度": "Effort",
+ "切换模型与推理强度": "Change model and reasoning effort",
+ "清除搜索": "Clear search",
+ "清空当前输出": "Clear current output",
+ "清空对话": "Clear conversation",
+ "取消": "Cancel",
+ "权限设置": "Permission settings",
+ "确认": "Confirm",
+ "确认完全访问": "Confirm full access",
+ "沙箱": "Sandbox",
+ "上下文用量": "Context usage",
+ "设置": "Settings",
+ "审批策略": "Approval policy",
+ "使用 config.toml 中的权限": "Use permissions from config.toml",
+ "使用左右方向键调整强度": "Use the left and right arrow keys to adjust effort",
+ "授权范围": "Authorization scope",
+ "授权与输入": "Approvals and input",
+ "刷新会话列表": "Refresh conversations",
+ "搜索最近会话": "Search recent conversations",
+ "提交后续变更要求": "Ask for follow-up changes",
+ "添加工作区上下文": "Add workspace context",
+ "添加文件": "Add files",
+ "添加文件及更多内容": "Add files and more",
+ "添加照片": "Add photos",
+ "推理强度": "Reasoning effort",
+ "完全访问": "Full access",
+ "完全访问允许 Codex 执行命令、访问互联网并编辑工作区之外的文件。": "Full access lets Codex run commands, use the internet, and edit files outside the workspace.",
+ "网页搜索": "Web search",
+ "未认证": "Unauthenticated",
+ "显示 Codex": "Show Codex",
+ "修改权限": "Change permissions",
+ "需要时询问": "Ask when needed",
+ "已附着当前会话": "Attached to current conversation",
+ "隐藏面板": "Hide panel",
+ "由 Codex 审批": "Let Codex decide",
+ "允许": "Allow",
+ "允许一次": "Allow once",
+ "暂无待处理请求": "No pending requests",
+ "暂无用量数据": "No usage data",
+ "展开面板": "Expand panel",
+ "正在连接": "Connecting",
+ "只读": "Read only",
+ "中断当前 turn": "Interrupt current turn",
+ "重新同步": "Resync",
+ "子代理": "Subagent",
+ "自定义": "Custom",
+ "最近会话": "Recent conversations",
+ "Codex 消息": "Codex messages",
+ "JSON 响应": "JSON response",
+ "语言": "Language",
+ "中文": "Chinese",
+ "跟随浏览器": "Use browser language",
+ "正在连接云端会话": "Connecting to cloud conversation",
+ "正在等待云端连接": "Waiting for cloud connection",
+ "云端连接已断开": "Cloud connection disconnected",
+ "云端连接配置无效": "Invalid cloud connection configuration",
+ "云端连接地址必须与当前页面同源": "The cloud connection URL must be same-origin",
+ "正在获取新的连接凭证": "Requesting new connection credentials",
+ "父页面已断开连接": "Disconnected by the parent page",
+ "当前 relay 需要 token": "This relay requires a token",
+ "WebSocket 未连接": "WebSocket is not connected",
+ "连接中": "Connecting",
+ "同步中": "Syncing",
+ "已连接": "Connected",
+ "认证失败,准备重连": "Authentication failed; preparing to reconnect",
+ "准备重连": "Preparing to reconnect",
+ "重连中": "Reconnecting",
+ "收到无法解析的 relay 消息": "Received an unreadable relay message",
+ "等待 relay 连接": "Waiting for relay connection",
+ "等待 VS Code 主机连接": "Waiting for VS Code host",
+ "VS Code 主机未连接": "VS Code host is disconnected",
+ "等待 VS Code 伴随扩展连接": "Waiting for the VS Code companion extension",
+ "VS Code 伴随扩展未连接": "VS Code companion extension is disconnected",
+ "等待在 VS Code 中打开 Codex 会话": "Open a Codex conversation in VS Code to continue",
+ "会话已关闭": "Conversation closed",
+ "VS Code 会话已关闭": "VS Code conversation closed",
+ "会话操作失败": "Conversation operation failed",
+ "当前任务结束或请求处理后才能切换": "You can switch after the current task or request finishes",
+ "目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它": "The target conversation did not return a VS Code snapshot. Open it in the official Codex panel first.",
+ "当前 relay 版本不支持此会话操作,请重启 relay": "This relay version does not support the conversation action. Restart the relay.",
+ "正在读取会话…": "Loading conversations...",
+ "正在切换会话…": "Switching conversation...",
+ "无法读取会话": "Unable to load conversations",
+ "没有匹配的会话": "No matching conversations",
+ "没有可附加的会话": "No attachable conversations",
+ "没有可控制的会话": "No controllable conversations",
+ "正在切换": "Switching",
+ "未打开": "Not open",
+ "当前": "Current",
+ "可切换": "Available",
+ "会话": "Conversation",
+ "当前角色不能创建会话": "Your current role cannot create conversations",
+ "正在创建新会话": "Creating a new conversation",
+ "无法创建新会话": "Unable to create a new conversation",
+ "当前任务仍在运行或等待授权,暂不能切换": "The current task is running or awaiting approval, so it cannot be switched yet",
+ "会话切换失败": "Conversation switch failed",
+ "正在确认会话": "Confirming conversation",
+ "正在加载会话": "Loading conversation",
+ "会话已切换": "Conversation switched",
+ "正在更新模型设置": "Updating model settings",
+ "模型设置已更新": "Model settings updated",
+ "无法更新模型设置": "Unable to update model settings",
+ "已停止": "Stopped",
+ "成功": "Succeeded",
+ "无输出": "No output",
+ "等待输出…": "Waiting for output...",
+ "执行步骤": "Action",
+ "正在读取文件": "Reading files",
+ "读取完成": "Finished reading",
+ "已读取文件运行了命令": "Read files and ran a command",
+ "已读取文件": "Read files",
+ "编辑了文件": "Edited files",
+ "已完成计划": "Completed plan",
+ "读取文件失败": "Failed to read files",
+ "已停止读取文件": "Stopped reading files",
+ "读取文件": "Read files",
+ "已运行命令": "Ran command",
+ "正在运行命令": "Running command",
+ "正在思考": "Thinking",
+ "正在制定计划": "Creating a plan",
+ "正在编辑文件": "Editing files",
+ "正在处理": "Working",
+ "已完成思考": "Finished thinking",
+ "计划完成": "Plan completed",
+ "文件编辑完成": "Finished editing files",
+ "工作说明": "Progress update",
+ "计划": "Plan",
+ "文件变更": "File changes",
+ "等待授权": "Waiting for approval",
+ "正在生成": "Generating",
+ "已中断": "Interrupted",
+ "失败": "Failed",
+ "已完成": "Completed",
+ "正在工作": "Working",
+ "正在等待你的回答": "Waiting for your answer",
+ "正在搜索网页": "Searching the web",
+ "执行失败": "Action failed",
+ "处理中": "Working",
+ "思考": "Reasoning",
+ "编辑文件": "Edit files",
+ "思考中": "Thinking",
+ "编辑中": "Editing",
+ "进行中": "In progress",
+ "异常": "Error",
+ "未读": "Unread",
+ "本地会话": "Local conversation",
+ "默认拒绝,请明确允许": "Denied by default; allow explicitly",
+ "需要远程确认或输入": "Remote confirmation or input is required",
+ "允许运行命令?": "Allow this command?",
+ "允许修改文件?": "Allow file changes?",
+ "需要扩大权限": "Additional permissions required",
+ "Codex 需要你的回答": "Codex needs your answer",
+ "需要外部服务确认": "External service confirmation required",
+ "Codex 请求确认": "Codex requests confirmation",
+ "高风险": "High risk",
+ "低风险": "Low risk",
+ "需确认": "Confirmation required",
+ "请输入": "Enter a response",
+ "提交回答": "Submit answer",
+ "发送自定义响应": "Send custom response",
+ "自定义响应不是有效 JSON": "The custom response is not valid JSON",
+ "响应不是有效 JSON": "The response is not valid JSON",
+ "远程参与者拒绝": "Denied by remote participant",
+ "状态": "Status",
+ "命令": "Command",
+ "详情": "Details",
+ "复制消息": "Copy message",
+ "复制命令": "Copy command",
+ "复制输出": "Copy output",
+ "未知": "Unknown",
+ "未知错误": "Unknown error",
+ "已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。": "Attached to the current VS Code Codex conversation. Messages, output, and approvals all return to that conversation.",
+ "当前为独立 app-server 模式。": "Currently using standalone app-server mode.",
+ "已附着现有会话": "Attached to existing conversation",
+ "通用 Codex 模型": "General-purpose Codex model",
+ "平衡速度与推理": "Balanced speed and reasoning",
+ "可用模型": "Available model",
+ "极低": "Minimal",
+ "轻度": "Low",
+ "标准": "Medium",
+ "深度": "High",
+ "极高": "Extra high",
+ "最大": "Maximum",
+ "此模型使用默认推理强度": "This model uses its default reasoning effort",
+ "返回简洁模型选择": "Return to simple model selection",
+ "显示高级模型选项": "Show advanced model options",
+ "自定义权限由 config.toml 管理": "Custom permissions are managed by config.toml",
+ "正在等待指示": "Waiting for instructions",
+ "正在工作": "Working",
+ "命令输出": "Command output",
+ "工具输出": "Tool output",
+ "发送 Steer": "Send steer",
+ "会话切换失败,已恢复原会话": "Conversation switch failed; restored the previous conversation",
+ "(空消息)": "(empty message)",
+ "今天": "Today",
+ "昨天": "Yesterday",
+ "未完成": "Not completed",
+ "步骤": "Step",
+ "查看图像": "View image",
+ "等待输入": "Waiting for input",
+ "读取文件运行命令失败": "Failed to read files and run a command",
+ "发送输入": "Send input",
+ "工具": "Tool",
+ "工具失败": "Tool failed",
+ "正在搜索": "Searching",
+ "你停止了工作": "You stopped working",
+ "关闭子代理": "Close subagent",
+ "恢复子代理": "Resume subagent",
+ "启动子代理": "Start subagent",
+ "搜索": "Search",
+ "文件": "File",
+ "新会话已在 VS Code 中打开": "The new conversation opened in VS Code",
+ "事件窗口已过期,请以当前快照为准": "The event window expired; the current snapshot is authoritative",
+ "执行状态未知,请等待主机恢复": "Execution status is unknown; wait for the host to recover",
+ "文件已截断": "File truncated",
+ "已拒绝": "Denied",
+ "已开始工作": "Started working",
+ "已添加工作区上下文": "Added workspace context",
+ "已添加网页搜索": "Added web search",
+ "运行命令": "Run command",
+ "整理上下文": "Compacting context",
+ "正在切换会话": "Switching conversation",
+ "MCP 工具": "MCP tool",
+ " · @ 可标记代理": " · @ to mention agents",
+ });
+
+ const EN_PATTERNS = Object.freeze([
+ [/^用时 1分钟(\d+)秒$/, "Worked for 1m{1}s"],
+ [/^用时 (\d+)分(\d+)秒$/, "Worked for {1}m{2}s"],
+ [/^用时 1分钟$/, "Worked for 1m"],
+ [/^用时 (\d+)分$/, "Worked for {1}m"],
+ [/^用时 (\d+)秒$/, "Worked for {1}s"],
+ [/^用时 (\d+)毫秒$/, "Worked for {1}ms"],
+ [/^用时\s+(.+)$/, "Worked for {1}"],
+ [/^已思考 1分钟(\d+)秒$/, "Thought for 1m{1}s"],
+ [/^已思考 (\d+)分(\d+)秒$/, "Thought for {1}m{2}s"],
+ [/^已思考 (\d+)秒$/, "Thought for {1}s"],
+ [/^已思考\s+(.+)$/, "Thought for {1}"],
+ [/^退出码\s+(.+)$/, "Exit code {1}"],
+ [/^正在读取\s+(.+)$/, "Reading {1}", [1]],
+ [/^已读取\s+(.+)$/, "Read {1}", [1]],
+ [/^读取失败\s*·\s*(.+)$/, "Failed to read {1}", [1]],
+ [/^已停止读取\s+(.+)$/, "Stopped reading {1}", [1]],
+ [/^读取\s+(.+)$/, "Read {1}", [1]],
+ [/^已读取这些内容\s*·\s*(\d+)\s*个文件(.*)$/, "Read these items · {1} files{2}"],
+ [/^已在\s+(.+)\s+内运行\s+(.+)$/, "Ran {2} in {1}", [2]],
+ [/^命令运行失败\s*·\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Command failed · {1} · {2}", [1]],
+ [/^命令运行失败\s*·\s*(.+)$/, "Command failed · {1}", [1]],
+ // Renderer-owned disclosure labels. Keep the captured command/model text
+ // intact; only the surrounding UI words are localized.
+ [/^命令\s*·\s*(.+)$/, "Command · {1}", [1]],
+ [/^已工具\s*·\s*(.+)$/, "Tool completed · {1}"],
+ [/^当前模型\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大),切换模型$/, "Current model: {1} {2}. Change model", [1]],
+ [/^已停止\s*(.+?)\s*·\s*((?:\d+毫秒|\d+秒|1分钟(?:\d+秒)?|\d+分(?:\d+秒)?))$/, "Stopped {1} · {2}", [1]],
+ [/^已运行\s*(.+)$/, "Ran {1}", [1]],
+ [/^命令运行失败\s*(.*)$/, "Command failed{1}", [1]],
+ [/^命令:\s*(.+?)(执行状态未知,请等待主机恢复)$/, "Command: {1} (execution status unknown; wait for the host to recover)", [1]],
+ [/^命令:\s*(.+)$/, "Command: {1}", [1]],
+ [/^已停止\s*(.+)$/, "Stopped {1}", [1]],
+ [/^正在运行\s+(.+)$/, "Running {1}", [1]],
+ [/^(.+?)\s*·\s*失败$/, "{1} · Failed"],
+ [/^(.+?)\s*·\s*已中断$/, "{1} · Interrupted"],
+ [/^(.+)\s+失败$/, "{1} failed"],
+ [/^编辑了文件\s*·\s*(.+)$/, "Edited files · {1}"],
+ [/^已完成计划\s*·\s*(.+)$/, "Completed plan · {1}"],
+ [/^(\d+)\/(\d+)\s*个会话$/, "{1}/{2} conversations"],
+ [/^(\d+)\s*个会话$/, "{1} conversations"],
+ [/^会话\s+(.+)$/, "Conversation {1}", [1]],
+ [/^工作区\s*·\s*(.+)$/, "Workspace · {1}", [1]],
+ [/^昨天\s+(.+)$/, "Yesterday {1}", [1]],
+ [/^正在切换到「(.+)」…?$/, "Switching to “{1}”...", [1]],
+ [/^你在\s+(.+)\s+后停止了$/, "You stopped after {1}"],
+ [/^执行失败\s*·\s*(.+)$/, "Action failed · {1}"],
+ [/^新会话创建失败:(.+)$/, "Unable to create a new conversation: {1}", [1]],
+ [/^模型设置更新失败:(.+)$/, "Unable to update model settings: {1}", [1]],
+ [/^(.+)(执行状态未知,请等待主机恢复)$/, "{1} (execution status unknown; wait for the host to recover)", [1]],
+ [/^(.+) 完成$/, "{1} completed", [1]],
+ [/^请求 #(.+) 已提交$/, "Request #{1} submitted"],
+ [/^请求 #(.+) 已发送,等待 VS Code 主机确认$/, "Request #{1} sent; waiting for the VS Code host"],
+ [/^无法读取 (.+)$/, "Unable to read {1}", [1]],
+ [/^\[图片附件:(.+)\]$/, "[Image attachment: {1}]", [1]],
+ [/^(.+) 已开始工作$/, "{1} started working", [1]],
+ [/^(.+) 已完成$/, "{1} completed", [1]],
+ [/^(.+) 已中断$/, "{1} interrupted", [1]],
+ [/^…(文件已截断)$/, "... (file truncated)"],
+ [/^当前模型\s+(.+),切换模型$/, "Current model: {1}. Change model", [1]],
+ [/^切换模型(当前\s+(.+?)\s+(极低|轻度|标准|深度|极高|最大))$/, "Change model (current: {1} {2})", [1]],
+ [/^切换模型(当前\s+(.+))$/, "Change model (current: {1})", [1]],
+ [/^修改权限,当前为(.+)$/, "Change permissions. Current: {1}"],
+ [/^修改权限(当前:(.+))$/, "Change permissions (current: {1})"],
+ [/^上下文已使用\s*(\d+)%(剩余\s*(\d+)%)$/, "Context used: {1}% ({2}% remaining)"],
+ [/^(\d+)%\s*已使用$/, "{1}% used"],
+ [/^剩余\s+(.+)\s+tokens$/, "{1} tokens remaining"],
+ [/^当前上下文\s+(.+)\s+tokens$/, "Current context: {1} tokens"],
+ [/^最近请求\s+(.+)\s+tokens$/, "Latest request: {1} tokens"],
+ [/^累计\s+(.+)\s+tokens$/, "Total: {1} tokens"],
+ [/^使用\s+(.+)$/, "Using {1}", [1]],
+ [/^已用时\s+(.+)$/, "Elapsed: {1}"],
+ [/^(\d+)\s*个后台代理(.*)$/, "{1} background agents{2}"],
+ [/^(\d+)毫秒$/, "{1}ms"],
+ [/^(\d+)秒$/, "{1}s"],
+ [/^1分钟(\d+)秒$/, "1m{1}s"],
+ [/^(\d+)分(\d+)秒$/, "{1}m{2}s"],
+ [/^1分钟(\d+秒)?$/, "1m{1}"],
+ [/^(\d+)分(\d+秒)?$/, "{1}m{2}"],
+ ]);
+ const ZH = Object.freeze(Object.fromEntries(Object.entries(EN).map(([source, translated]) => [translated, source])));
+
+ let currentLocale = "zh-CN";
+ let observer = null;
+ const textSources = new WeakMap();
+ const textRendered = new WeakMap();
+ const attributeSources = new WeakMap();
+ const attributeRendered = new WeakMap();
+
+ function normalizeLocale(value) {
+ const locale = String(value || "").trim().replace("_", "-").toLowerCase();
+ return locale.startsWith("zh") ? "zh-CN" : "en-US";
+ }
+
+ function embeddedMode() {
+ if (root?.AetherVscodexEmbed?.active) return true;
+ try { return new URLSearchParams(root?.location?.search || "").get("embed") === "aether"; }
+ catch { return false; }
+ }
+
+ function interpolate(template, values) {
+ return String(template).replace(/\{(\d+)\}/g, (_, index) => values[Number(index)] ?? "");
+ }
+
+ function translate(value, locale = currentLocale, depth = 0) {
+ const source = String(value ?? "");
+ if (!source) return source;
+ if (normalizeLocale(locale) === "zh-CN") return ZH[source] || source;
+ if (Object.prototype.hasOwnProperty.call(EN, source)) return EN[source];
+ for (const [pattern, template, rawIndexes] of EN_PATTERNS) {
+ const match = source.match(pattern);
+ if (match) {
+ const translatedMatch = match.map((part, index) => index === 0
+ ? part
+ : rawIndexes?.includes(index) ? part
+ : depth < 6 ? translate(part, locale, depth + 1) : (EN[part] || part));
+ return interpolate(template, translatedMatch);
+ }
+ }
+ return source;
+ }
+
+ function shouldSkipTextNode(node) {
+ const parent = node?.parentElement;
+ return Boolean(parent?.closest?.("code, pre, .message-body, .request-summary, .request-questions, .request-json, .request-command, .diff-output, .terminal-output, .session-option-title, .subagent-name, .subagent-summary-label"));
+ }
+
+ function translateTextNode(node) {
+ if (!node || shouldSkipTextNode(node)) return;
+ const current = node.nodeValue;
+ const previousRendered = textRendered.get(node);
+ if (!textSources.has(node) || current !== previousRendered) textSources.set(node, current);
+ const source = textSources.get(node);
+ const leading = source.match(/^\s*/)?.[0] || "";
+ const trailing = source.match(/\s*$/)?.[0] || "";
+ const core = source.slice(leading.length, source.length - trailing.length);
+ if (!core) return;
+ const translated = translate(core);
+ const rendered = `${leading}${translated}${trailing}`;
+ textRendered.set(node, rendered);
+ if (rendered !== current) node.nodeValue = rendered;
+ }
+
+ function translateAttributes(element) {
+ if (!element?.getAttribute || element.closest?.(".message-body, pre, code")) return;
+ let sources = attributeSources.get(element);
+ let renderedValues = attributeRendered.get(element);
+ if (!sources) { sources = new Map(); attributeSources.set(element, sources); }
+ if (!renderedValues) { renderedValues = new Map(); attributeRendered.set(element, renderedValues); }
+ for (const attribute of ["title", "aria-label", "placeholder", "data-placeholder"]) {
+ if (!element.hasAttribute(attribute)) continue;
+ const current = element.getAttribute(attribute);
+ if (!sources.has(attribute) || current !== renderedValues.get(attribute)) sources.set(attribute, current);
+ const source = sources.get(attribute);
+ const translated = translate(source);
+ renderedValues.set(attribute, translated);
+ if (translated !== current) element.setAttribute(attribute, translated);
+ }
+ }
+
+ function translateTree(node) {
+ if (!root?.document || !node) return;
+ if (node.nodeType === 3) {
+ translateTextNode(node);
+ return;
+ }
+ if (node.nodeType !== 1 && node.nodeType !== 9 && node.nodeType !== 11) return;
+ if (node.nodeType === 1) translateAttributes(node);
+ const walker = root.document.createTreeWalker(node, root.NodeFilter.SHOW_ELEMENT | root.NodeFilter.SHOW_TEXT);
+ for (let current = walker.nextNode(); current; current = walker.nextNode()) {
+ if (current.nodeType === 3) translateTextNode(current);
+ else translateAttributes(current);
+ }
+ }
+
+ function applyDocument() {
+ if (!root?.document) return;
+ root.document.documentElement.lang = currentLocale;
+ translateTree(root.document.body);
+ const selector = root.document.getElementById("localeSelect");
+ if (selector && selector.value !== currentLocale) selector.value = currentLocale;
+ }
+
+ function setLocale(value, options = {}) {
+ currentLocale = SUPPORTED.has(value) ? value : normalizeLocale(value);
+ if (options.persist !== false && root?.localStorage && !embeddedMode()) {
+ try { root.localStorage.setItem(STORAGE_KEY, currentLocale); } catch { /* storage may be disabled */ }
+ }
+ applyDocument();
+ if (root?.CustomEvent) root.dispatchEvent?.(new root.CustomEvent("aether-vscodex:locale", { detail: { locale: currentLocale } }));
+ return currentLocale;
+ }
+
+ function initialLocale() {
+ if (embeddedMode()) return normalizeLocale(root?.navigator?.language);
+ try {
+ const saved = root?.localStorage?.getItem(STORAGE_KEY);
+ if (SUPPORTED.has(saved)) return saved;
+ } catch { /* storage may be disabled */ }
+ return normalizeLocale(root?.navigator?.language);
+ }
+
+ function start() {
+ if (!root?.document) return;
+ currentLocale = initialLocale();
+ applyDocument();
+ if (typeof root.MutationObserver === "function" && !observer) {
+ observer = new root.MutationObserver((records) => {
+ if (currentLocale === "zh-CN") return;
+ for (const record of records) {
+ if (record.type === "characterData") translateTextNode(record.target);
+ else if (record.type === "attributes") translateAttributes(record.target);
+ else for (const node of record.addedNodes) translateTree(node);
+ }
+ });
+ observer.observe(root.document.documentElement, {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ attributes: true,
+ attributeFilter: ["title", "aria-label", "placeholder", "data-placeholder"],
+ });
+ }
+ }
+
+ const api = {
+ locale: () => currentLocale,
+ normalizeLocale,
+ setLocale,
+ start,
+ t: (value) => translate(value),
+ translate,
+ translateTree,
+ messages: { "zh-CN": Object.freeze({}), "en-US": EN },
+ };
+
+ if (root?.document) {
+ if (root.document.readyState === "loading") root.document.addEventListener("DOMContentLoaded", start, { once: true });
+ else start();
+ }
+ return api;
+});
diff --git a/aether-vscodex/public/index.html b/aether-vscodex/public/index.html
new file mode 100644
index 000000000..9137c092d
--- /dev/null
+++ b/aether-vscodex/public/index.html
@@ -0,0 +1,303 @@
+
+
+
+
+
+
+ Codex
+
+
+
+
+
+
+ 正在连接
+ 未认证
+
+
+
+
+
+
设置
+
+
+
+
+
+
+
当前会话
+
+ - 工作区
- -
+ - 模式
- 本地模式
+ - thread
- -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 会话设置
+
+
+
+
+
+
+
+ thread-
+ turn-
+
+
+
+
+ 连接设置
+
+
+ 已附着当前会话
+ seq -
+ 等待连接
+
+ -
+ 授权与输入0
暂无待处理请求
+
+
+
+
+ !
+
+
+
+
+
+ 查看请求数据
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aether-vscodex/public/style.css b/aether-vscodex/public/style.css
new file mode 100644
index 000000000..961bf7203
--- /dev/null
+++ b/aether-vscodex/public/style.css
@@ -0,0 +1,1817 @@
+:root {
+ color-scheme: dark light;
+ --color-background: var(--vscode-editor-background, #181818);
+ --color-background-under: var(--vscode-sideBar-background, #141414);
+ --color-surface: var(--vscode-sideBar-background, #1b1b1b);
+ --color-surface-secondary: var(--vscode-input-background, #202020);
+ --color-surface-tertiary: var(--vscode-titleBar-activeBackground, #1f1f1f);
+ --color-surface-hover: var(--vscode-list-hoverBackground, #2a2d2e);
+ --color-terminal-surface: var(--vscode-textCodeBlock-background, #242526);
+ --color-terminal-border: var(--vscode-input-border, #373738);
+ --color-border: var(--vscode-panel-border, #303030);
+ --color-border-strong: var(--vscode-input-border, #454545);
+ --color-text: var(--vscode-foreground, #d4d4d4);
+ --color-text-secondary: var(--vscode-descriptionForeground, #9d9d9d);
+ --color-text-tertiary: color-mix(in srgb, var(--color-text-secondary) 72%, transparent);
+ --color-info: var(--vscode-textLink-foreground, #75beff);
+ --color-warning: var(--vscode-editorWarning-foreground, #d7ba7d);
+ --color-danger: var(--vscode-editorError-foreground, #f48771);
+ --color-success: var(--vscode-testing-iconPassed, #4ec9a0);
+ --color-user-message: color-mix(in srgb, var(--color-text) 5%, transparent);
+ --font-ui: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
+ --font-mono: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
+ --font-size: var(--vscode-font-size, 13px);
+ --code-size: var(--vscode-editor-font-size, 12px);
+ --item-gap: 16px;
+ --panel-width: 500px;
+ --composer-shadow: 0 4px 16px rgba(0, 0, 0, .13);
+ font-family: var(--font-ui);
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+ --color-background: #ffffff;
+ --color-background-under: #f3f3f3;
+ --color-surface: #f8f8f8;
+ --color-surface-secondary: #f3f3f3;
+ --color-surface-tertiary: #eeeeee;
+ --color-surface-hover: #e8e8e8;
+ --color-terminal-surface: #f5f5f5;
+ --color-terminal-border: #d6d6d6;
+ --color-border: #dddddd;
+ --color-border-strong: #c8c8c8;
+ --color-text: #242424;
+ --color-text-secondary: #616161;
+ --color-info: #006ab1;
+ --color-warning: #8a6100;
+ --color-danger: #b42318;
+ --color-success: #267a3e;
+ --color-user-message: rgba(0, 0, 0, .045);
+ --composer-shadow: 0 4px 16px rgba(0, 0, 0, .08);
+}
+
+:root[data-theme="dark"] { color-scheme: dark; }
+
+* { box-sizing: border-box; }
+html, body { height: 100%; }
+html { background: var(--color-background-under); }
+body {
+ margin: 0;
+ min-width: 280px;
+ overflow: hidden;
+ background: var(--color-background-under);
+ color: var(--color-text);
+ font: var(--font-size)/1.45 var(--font-ui);
+}
+
+button, input, textarea, select { font: inherit; }
+button { cursor: pointer; }
+button:disabled { cursor: default; opacity: .42; }
+button:focus-visible, summary:focus-visible {
+ outline: 1px solid var(--color-info);
+ outline-offset: 1px;
+}
+
+.codex-panel {
+ width: min(100%, var(--panel-width));
+ height: 100dvh;
+ min-height: 0;
+ margin: 0 auto;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ background: var(--color-background);
+ border-inline: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
+}
+
+.connection { display: none; }
+
+.icon-button {
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ min-height: 24px;
+ padding: 0;
+ display: inline-flex;
+ color: var(--color-text-secondary);
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+}
+.icon-button:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); }
+.icon-button svg, .composer svg, .mode-icon {
+ width: 16px;
+ height: 16px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.35;
+}
+
+.chat-shell {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.chat-header {
+ align-items: center;
+ flex: 0 0 46px;
+ min-height: 46px;
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 4px 12px;
+ background: var(--color-background);
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
+}
+.thread-heading { align-items: center; display: flex; flex: 1 1 auto; min-width: 0; gap: 2px; }
+.header-back-button {
+ flex: 0 0 24px;
+ width: 24px;
+ height: 24px;
+ color: var(--color-text-secondary);
+ opacity: .76;
+}
+.header-back-button:hover:not(:disabled) { background: transparent; opacity: 1; }
+.header-back-button svg { width: 12px; height: 12px; }
+.thread-picker-button {
+ min-width: 0;
+ max-width: min(360px, 62vw);
+ min-height: 28px;
+ padding: 2px 4px;
+ display: inline-flex;
+ align-items: center;
+ flex: 0 1 auto;
+ color: var(--color-text);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+}
+.thread-picker-button:hover,
+.thread-picker-button[aria-expanded="true"] { background: transparent; opacity: .8; }
+.thread-picker-button h2 {
+ margin: 0;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--color-text);
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 24px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.status-text {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+.thread-actions { align-items: center; display: flex; flex: 0 0 auto; gap: 2px; }
+.header-history-button { margin-right: 1px; }
+.new-session-button { margin-left: 1px; color: var(--color-text-secondary); }
+.panel-popover {
+ position: absolute;
+ z-index: 30;
+ top: 53px;
+ right: 10px;
+ min-width: 150px;
+ padding: 5px;
+ color: var(--color-text);
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
+}
+.panel-popover[hidden] { display: none; }
+.panel-menu { display: grid; gap: 2px; }
+.panel-menu button {
+ min-height: 28px;
+ padding: 5px 8px;
+ color: var(--color-text);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+ font-size: 11px;
+}
+.panel-menu button:hover { background: var(--color-surface-hover); }
+.details-popover { width: min(320px, calc(100vw - 20px)); min-width: 220px; padding: 10px 12px; }
+.popover-title { margin-bottom: 8px; font-size: 11px; font-weight: 600; }
+.settings-shortcuts { display: grid; gap: 2px; }
+.settings-shortcuts button {
+ min-height: 30px;
+ padding: 5px 7px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--color-text-secondary);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+ font-size: 10px;
+}
+.settings-shortcuts button:hover { color: var(--color-text); background: var(--color-surface-hover); }
+.settings-locale {
+ min-height: 30px;
+ padding: 5px 7px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--color-text-secondary);
+ font-size: 10px;
+}
+.settings-locale select {
+ max-width: 124px;
+ padding: 2px 5px;
+ color: var(--color-text);
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+}
+body.embed-aether #localeSetting { display: none; }
+.settings-shortcuts button span:last-child {
+ max-width: 110px;
+ overflow: hidden;
+ color: var(--color-text-tertiary);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.settings-divider { height: 1px; margin: 7px 0; background: var(--color-border); }
+.popover-subtitle { margin-bottom: 5px; color: var(--color-text-tertiary); font-size: 9px; }
+.details-popover dl { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 5px 8px; margin: 0; font-size: 10px; }
+.details-popover dt { color: var(--color-text-secondary); }
+.details-popover dd { min-width: 0; margin: 0; overflow: hidden; color: var(--color-text); text-overflow: ellipsis; white-space: nowrap; }
+.session-picker {
+ right: auto;
+ left: 10px;
+ width: min(330px, calc(100vw - 20px));
+ min-width: 0;
+ padding: 7px;
+}
+.session-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 1px 2px 5px 4px; }
+.session-picker-header .popover-title { margin: 0; }
+.session-picker-refresh {
+ width: 24px;
+ height: 24px;
+ min-height: 24px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-tertiary);
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+}
+.session-picker-refresh:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); }
+.session-picker-refresh svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.25; }
+.session-search {
+ min-height: 28px;
+ margin: 1px 2px 5px;
+ padding: 0 7px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--color-text-tertiary);
+ background: var(--color-background);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+}
+.session-search:focus-within {
+ color: var(--color-text-secondary);
+ border-color: var(--color-info);
+}
+.session-search > svg { width: 13px; height: 13px; flex: 0 0 13px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.25; }
+.session-search input {
+ width: 100%;
+ min-width: 0;
+ height: 26px;
+ padding: 0;
+ color: var(--color-text);
+ background: transparent;
+ border: 0;
+ outline: 0;
+ font-size: 11px;
+}
+.session-search input::placeholder { color: var(--color-text-tertiary); }
+.session-search input::-webkit-search-cancel-button { appearance: none; }
+.session-search-clear {
+ width: 18px;
+ height: 18px;
+ min-height: 18px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 18px;
+ color: var(--color-text-tertiary);
+ background: transparent;
+ border: 0;
+ border-radius: 4px;
+}
+.session-search-clear:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); }
+.session-search-clear svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.35; }
+.session-picker-status { min-height: 16px; padding: 0 5px 4px; color: var(--color-text-tertiary); font-size: 10px; }
+.session-picker-status[data-tone="warning"] { color: var(--color-warning); }
+.session-list { max-height: min(58dvh, 390px); overflow-y: auto; overscroll-behavior: contain; }
+.session-list:empty { display: none; }
+.session-list:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-info) 72%, transparent); outline-offset: -1px; }
+.session-list-empty { padding: 17px 9px; color: var(--color-text-tertiary); font-size: 11px; text-align: center; }
+.session-option {
+ position: relative;
+ width: 100%;
+ min-height: 53px;
+ padding: 7px 8px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 2px 8px;
+ color: var(--color-text);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+}
+.session-option:hover:not(:disabled), .session-option[aria-selected="true"], .session-option[data-focused="true"] { background: var(--color-surface-hover); }
+.session-option[aria-selected="true"] { box-shadow: inset 2px 0 var(--color-info); }
+.session-option[data-focused="true"]::after {
+ position: absolute;
+ inset: 1px;
+ border: 1px solid color-mix(in srgb, var(--color-info) 55%, transparent);
+ border-radius: 5px;
+ content: "";
+ pointer-events: none;
+}
+.session-option:disabled { opacity: .55; }
+.session-option-title { min-width: 0; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.session-option-time { color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; }
+.session-option-meta { grid-column: 1; min-width: 0; overflow: hidden; color: var(--color-text-tertiary); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.session-option-state { grid-column: 2; min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; color: var(--color-success); font-size: 9px; white-space: nowrap; }
+.session-status-dot { width: 6px; height: 6px; flex: 0 0 6px; background: var(--color-success); border-radius: 50%; }
+.session-option[data-status="working"] .session-status-dot,
+.session-option[data-status="thinking"] .session-status-dot,
+.session-option[data-status="editing"] .session-status-dot { background: var(--color-warning); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-warning) 18%, transparent); }
+.session-option[data-status="waiting"] .session-status-dot,
+.session-option[data-status="approval"] .session-status-dot,
+.session-option[data-status="unread"] .session-status-dot { background: var(--color-info); }
+.session-option[data-status="error"] .session-status-dot { background: var(--color-danger); }
+.session-option[data-unread="true"] .session-option-title { font-weight: 600; }
+.session-option[data-unread="true"] .session-option-time { color: var(--color-info); }
+.session-option[data-switching="true"] .session-option-state { color: var(--color-warning); }
+.session-option[data-switching="true"] .session-status-dot { background: var(--color-warning); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-warning) 18%, transparent); }
+.session-option[data-available="false"] .session-option-state { color: var(--color-text-tertiary); }
+.session-option[data-available="false"] .session-status-dot { background: var(--color-text-tertiary); box-shadow: none; }
+.session-picker[data-switching="true"] .session-option { pointer-events: none; }
+.restore-panel {
+ position: fixed;
+ right: 14px;
+ bottom: 14px;
+ z-index: 50;
+ min-height: 30px;
+ padding: 5px 10px;
+ color: var(--color-text);
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 7px;
+ box-shadow: var(--composer-shadow);
+}
+.restore-panel[hidden] { display: none; }
+body.panel-expanded .codex-panel { width: min(100%, 900px); }
+body.panel-hidden .codex-panel { display: none; }
+
+.chat-panel {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.chat-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scroll-behavior: auto;
+ /* JS keeps an explicit timeline anchor during disclosure/stream updates. */
+ overflow-anchor: none;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border) transparent;
+ scrollbar-gutter: stable;
+ padding: 16px 18px var(--thread-scroll-padding-bottom, 112px);
+ scroll-padding-bottom: var(--thread-scroll-padding-bottom, 112px);
+}
+.chat-scroll:hover, .chat-scroll:focus-within { scrollbar-color: var(--color-border-strong) transparent; }
+.chat-scroll::-webkit-scrollbar { width: 10px; }
+.chat-scroll::-webkit-scrollbar-track { background: transparent; }
+.chat-scroll::-webkit-scrollbar-thumb { background: var(--color-border); border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; }
+.chat-scroll:hover::-webkit-scrollbar-thumb, .chat-scroll:focus-within::-webkit-scrollbar-thumb { background: var(--color-border-strong); background-clip: padding-box; }
+.output { color: var(--color-text); font-size: var(--font-size); word-break: break-word; }
+.output:empty::before { content: none; }
+
+.message {
+ position: relative;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ margin: 0 0 var(--item-gap);
+}
+.message.user { align-items: flex-end; justify-content: flex-start; }
+.message-content { min-width: 0; max-width: min(100%, 520px); overflow-wrap: anywhere; line-height: 1.5; }
+.message.user .message-content {
+ max-width: min(77%, 520px);
+ padding: 8px 12px;
+ color: var(--color-text);
+ background: var(--color-user-message);
+ border: 0;
+ border-radius: 16px;
+}
+.message.assistant .message-content { max-width: 100%; }
+.message.system .message-content, .message.error .message-content { color: var(--color-text-secondary); font-size: 12px; }
+.message.activity .message-content { font-size: var(--font-size); }
+.message.activity .message-details > summary { font-size: var(--font-size); }
+.message.error .message-content { color: var(--color-danger); }
+.message.tool .message-content { width: 100%; color: var(--color-text-secondary); }
+.message.streaming .message-content::after { content: "▍"; margin-left: 2px; color: var(--color-text-secondary); }
+.message-meta {
+ margin-top: 5px;
+ color: var(--color-text-tertiary);
+ font-size: 10px;
+ line-height: 1.35;
+ font-variant-numeric: tabular-nums;
+ opacity: 0;
+ transition: opacity .12s ease;
+}
+.message:hover .message-meta, .message:focus-within .message-meta { opacity: 1; }
+.message.user .message-meta { text-align: right; }
+.date-separator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 52px;
+ padding: 16px 0;
+ margin: 0;
+ color: var(--color-text-tertiary);
+ font-size: 13px;
+ font-weight: 400;
+ line-height: 20px;
+ user-select: none;
+ white-space: nowrap;
+}
+.date-separator::before, .date-separator::after { display: none; }
+.date-separator time { font: inherit; color: inherit; }
+.date-separator .date-label { font-weight: 500; }
+.date-separator .date-time { font-weight: 400; }
+
+.turn-divider {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 4px;
+ margin: 4px 0 12px;
+ color: var(--color-text-secondary);
+}
+.turn-divider-toggle {
+ align-items: center;
+ align-self: flex-start;
+ display: inline-flex;
+ gap: 4px;
+ min-height: 22px;
+ padding: 1px 2px;
+ color: color-mix(in srgb, var(--color-text) 60%, transparent);
+ font-size: var(--font-size);
+ line-height: 20px;
+ text-align: left;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+.turn-divider-toggle:hover { color: var(--color-text); background: var(--color-surface-hover); }
+.turn-divider-toggle svg {
+ width: 12px;
+ height: 12px;
+ flex: 0 0 12px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.35;
+ transition: transform .12s ease;
+}
+.turn-divider-toggle[aria-expanded="true"] svg { transform: rotate(90deg); }
+.turn-divider-rule { width: 100%; height: 1px; background: var(--color-border); }
+
+.markdown-body p { margin: 0; }
+.markdown-body p + p, .markdown-body ul + p, .markdown-body ol + p,
+.markdown-body pre + p, .markdown-body blockquote + p, .markdown-body table + p { margin-top: 11px; }
+.markdown-body h1, .markdown-body h2, .markdown-body h3 {
+ margin: 0 0 8px;
+ color: inherit;
+ font-weight: 600;
+ line-height: 1.3;
+}
+.markdown-body h1 { font-size: 1.45em; }
+.markdown-body h2 { font-size: 1.25em; }
+.markdown-body h3 { font-size: 1.1em; }
+.markdown-body ul, .markdown-body ol { margin: 8px 0; padding-inline-start: 22px; }
+.markdown-body li + li { margin-top: 4px; }
+.markdown-body blockquote { margin: 10px 0; padding-inline-start: 11px; color: var(--color-text-secondary); border-inline-start: 2px solid var(--color-border-strong); }
+.markdown-body hr { height: 1px; margin: 14px 0; background: var(--color-border); border: 0; }
+.markdown-body a { color: var(--color-info); text-decoration: underline; text-underline-offset: 2px; }
+.markdown-body code, .message code { padding: 1px 4px; color: inherit; font: var(--code-size)/1.35 var(--font-mono); background: color-mix(in srgb, var(--color-surface-hover) 85%, transparent); border-radius: 4px; }
+.markdown-body pre, .message pre {
+ max-width: 100%;
+ margin: 10px 0;
+ padding: 10px 11px;
+ overflow: auto;
+ color: var(--color-text);
+ font: var(--code-size)/1.5 var(--font-mono);
+ white-space: pre;
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+}
+.markdown-body pre code, .message pre code { padding: 0; background: transparent; }
+.markdown-body table { width: 100%; margin: 10px 0; border-collapse: collapse; font-size: .95em; }
+.markdown-body th, .markdown-body td { padding: 5px 7px; text-align: left; border: 1px solid var(--color-border); }
+.markdown-body th { color: var(--color-text); background: var(--color-surface-secondary); font-weight: 600; }
+.markdown-body .task-list-item { list-style: none; margin-inline-start: -20px; }
+.markdown-body .task-list-item input { width: 13px; height: 13px; margin: 0 6px 0 0; vertical-align: -2px; accent-color: var(--color-info); }
+
+.message-actions {
+ position: static;
+ min-height: 18px;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-top: 1px;
+ opacity: 0;
+ transition: opacity .12s ease;
+}
+.message.user .message-actions { justify-content: flex-end; }
+.message:hover .message-actions, .message:focus-within .message-actions { opacity: 1; }
+.message-action {
+ min-height: 18px;
+ padding: 1px 4px;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+ background: transparent;
+ border: 0;
+ border-radius: 4px;
+}
+.message-action:hover { color: var(--color-text); background: var(--color-surface-hover); }
+.message-action svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.25; }
+.message-actions .message-meta { margin: 0 3px; opacity: 1; }
+
+.message-details {
+ min-width: min(100%, 420px);
+ overflow: hidden;
+ background: transparent;
+ border: 0;
+}
+.message-details > .details-body {
+ /* Managed disclosures stay mounted; app.js animates this measured box. */
+ display: block;
+ will-change: height, opacity;
+}
+.message-details > summary {
+ min-width: 0;
+ padding: 2px 0;
+ overflow: hidden;
+ color: var(--color-text-secondary);
+ font-size: 12px;
+ line-height: 1.5;
+ list-style: none;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.message-details > summary::-webkit-details-marker { display: none; }
+.message-details > summary::after {
+ content: "›";
+ display: inline-block;
+ width: 12px;
+ margin-left: 4px;
+ color: var(--color-text-secondary);
+ font-size: 15px;
+ opacity: 0;
+ transition: transform .18s cubic-bezier(.33,1,.68,1), opacity .12s ease;
+}
+.message-details > summary:hover::after,
+.message-details > summary:focus-visible::after,
+.message-details[data-expanded="true"] > summary::after { opacity: 1; }
+.message-details[data-expanded="true"] > summary::after { transform: rotate(90deg); }
+.message-details .details-body {
+ max-height: 260px;
+ margin-top: 5px;
+ padding: 0;
+ overflow: auto;
+ color: var(--color-text-secondary);
+ font: inherit;
+ line-height: 1.5;
+ white-space: normal;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+}
+.message.system .message-details > summary { color: var(--color-text-secondary); }
+.message.system .message-details > summary::after { color: var(--color-text-secondary); }
+.message.activity { margin-bottom: 4px; }
+.message.turn-collapsed {
+ /* The outer worked-for disclosure removes its activity units from layout. */
+ height: 0 !important;
+ min-height: 0 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: hidden !important;
+ visibility: hidden;
+ pointer-events: none;
+ opacity: 0;
+}
+.message.turn-collapsed * { margin-block: 0 !important; }
+.message[data-turn-expanded="false"] .message-details > summary { pointer-events: none; }
+.message.activity + .message.assistant { padding-top: 0; border-top: 0; }
+.message.activity[data-kind="commentary"] .message-details > summary { display: none; }
+.message.activity[data-kind="commentary"] .message-details > .details-body { margin-top: 0; }
+.message.activity[data-status="inProgress"] .message-details > summary { color: var(--color-text); }
+.message.activity[data-status="inProgress"][data-kind="reasoning"] .message-details > summary {
+ background: linear-gradient(90deg, var(--color-text-secondary), var(--color-text), var(--color-text-secondary));
+ background-size: 220% 100%;
+ background-clip: text;
+ -webkit-background-clip: text;
+ color: transparent;
+ animation: thinking-shimmer 1.8s linear infinite;
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .details-body {
+ max-height: none;
+ margin-top: 8px;
+ padding: 0;
+ overflow: visible;
+ color: var(--color-text-secondary);
+ font: inherit;
+ white-space: normal;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .message-details { width: 100%; min-width: 0; }
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .message-details > summary {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ width: 100%;
+ max-width: 100%;
+ color: var(--color-text-secondary);
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .message-details > summary::after {
+ flex: 0 0 12px;
+ margin-left: 0;
+}
+.message.activity[data-kind="subagent"] .message-details { width: 100%; min-width: 0; }
+.message.activity[data-kind="subagent"] .message-details > summary {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ max-width: 100%;
+ color: var(--color-text-secondary);
+}
+.message.activity[data-kind="subagent"] .message-details > summary::after {
+ flex: 0 0 12px;
+ margin-left: 0;
+}
+.subagent-summary-chip {
+ min-width: 0;
+ max-width: min(48%, 192px);
+ min-height: 24px;
+ padding: 1px 8px 1px 5px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ overflow: hidden;
+ color: var(--color-text-secondary);
+ vertical-align: middle;
+ background: transparent;
+ border: 1px solid color-mix(in srgb, var(--color-border-strong) 72%, transparent);
+ border-radius: 999px;
+}
+.subagent-summary-chip:hover { color: var(--color-text); border-color: var(--color-border-strong); background: var(--color-surface-hover); }
+.subagent-summary-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.subagent-summary-status {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--color-text-secondary);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.activity-summary-icon {
+ width: 13px;
+ height: 13px;
+ flex: 0 0 13px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.15;
+ opacity: .75;
+}
+.activity-summary-icon.subagent-summary-icon {
+ fill: currentColor;
+ stroke: none;
+ color: var(--color-success);
+ opacity: .95;
+}
+.subagent-summary-chip .subagent-summary-icon { width: 14px; height: 14px; flex: 0 0 14px; }
+.activity-summary-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.terminal-body { width: 100%; }
+.terminal-shell {
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--color-text-secondary);
+ background: var(--color-terminal-surface);
+ border: 1px solid var(--color-terminal-border);
+ border-radius: 10px;
+}
+.terminal-shell-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 25px;
+ overflow: hidden;
+ padding: 4px 8px;
+ color: var(--color-text-secondary);
+ font: 12px/17px var(--font-ui);
+ user-select: none;
+ background: transparent;
+}
+.terminal-shell-label {
+ min-width: 0;
+ overflow: hidden;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.terminal-file-path {
+ min-width: 0;
+ overflow: hidden;
+ padding: 3px 8px 5px;
+ color: var(--color-text-secondary);
+ font: var(--code-size)/1.45 var(--font-mono);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
+}
+.read-body {
+ display: grid;
+ gap: 2px;
+ padding: 2px 0 3px;
+ color: var(--color-text-secondary);
+ font-size: 11px;
+}
+.read-path-list { display: grid; gap: 1px; }
+.read-path-row {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 7px;
+ color: var(--color-text-secondary);
+ line-height: 1.45;
+}
+.read-path-row > span:last-child {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.read-path-row:hover { color: var(--color-text); background: var(--color-surface-hover); border-radius: 4px; }
+.read-path-icon {
+ width: 13px;
+ height: 13px;
+ flex: 0 0 13px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.1;
+ opacity: .72;
+}
+.read-output {
+ max-height: 140px;
+ margin: 4px 7px 0;
+ padding: 6px 7px;
+ overflow: auto;
+ color: var(--color-text-secondary);
+ font: var(--code-size)/1.5 var(--font-mono);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: 5px;
+}
+.read-empty { padding: 3px 7px; color: var(--color-text-tertiary); }
+.terminal-shell-actions {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 1px;
+ padding-right: 5px;
+}
+.terminal-action {
+ width: 20px;
+ height: 20px;
+ min-height: 20px;
+ padding: 2px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-tertiary);
+ background: transparent;
+ border: 0;
+ border-radius: 4px;
+ opacity: 0;
+ transition: opacity .12s ease, color .12s ease, background .12s ease;
+}
+.terminal-action:focus-visible { opacity: 1; }
+.terminal-action:hover { color: var(--color-text); background: var(--color-surface-hover); }
+.terminal-action[data-copied="true"] { color: var(--color-success); opacity: 1; }
+.terminal-action svg {
+ width: 13px;
+ height: 13px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.15;
+}
+.terminal-command-line {
+ position: relative;
+ display: flex;
+ align-items: baseline;
+ gap: 1ch;
+ min-width: 0;
+ padding: 8px 28px 0 8px;
+ color: var(--color-text-secondary);
+ font: var(--code-size)/1.5 var(--font-mono);
+ white-space: pre-wrap;
+ word-break: break-word;
+ cursor: pointer;
+ will-change: height;
+ scrollbar-gutter: stable;
+}
+.terminal-prompt {
+ flex: 0 0 auto;
+ color: var(--color-text-tertiary);
+ user-select: none;
+}
+.terminal-command-line code {
+ min-width: 0;
+ max-width: 100%;
+ padding: 0;
+ overflow-wrap: anywhere;
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ color: var(--color-text-secondary);
+ font: inherit;
+ background: transparent;
+}
+.terminal-command-line[data-expanded="true"] code {
+ display: block;
+ overflow: visible;
+ -webkit-line-clamp: unset;
+}
+.terminal-command-chevron {
+ width: 12px;
+ height: 12px;
+ flex: 0 0 12px;
+ margin-left: auto;
+ color: var(--color-text-tertiary);
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.2;
+ transition: transform .18s cubic-bezier(.33,1,.68,1), color .12s ease;
+}
+.terminal-command-line[data-expanded="true"] .terminal-command-chevron { transform: rotate(180deg); color: var(--color-text-secondary); }
+.terminal-command-line:hover .terminal-command-chevron,
+.terminal-command-line:focus-visible .terminal-command-chevron { color: var(--color-text); }
+.terminal-command-action {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+}
+.terminal-command-line:hover .terminal-command-action,
+.terminal-command-action:focus-visible { opacity: 1; }
+.terminal-output-wrap {
+ position: relative;
+ min-height: 20px;
+ margin-top: 0;
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ max-height: 144px;
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ overflow-x: auto;
+ overflow-y: auto;
+ color: var(--color-text-secondary);
+ font: var(--code-size)/1.5 var(--font-mono);
+ font-weight: 500;
+ white-space: pre;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-border) transparent;
+ scrollbar-gutter: stable;
+}
+.terminal-output-content {
+ display: block;
+ width: max-content;
+ min-width: 100%;
+ min-height: 18px;
+ margin: 0;
+ padding: 8px;
+ box-sizing: border-box;
+ color: inherit;
+ font: inherit;
+ white-space: inherit;
+}
+.terminal-output-empty { min-height: 34px; }
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output:hover {
+ scrollbar-color: var(--color-border-strong) transparent;
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output::-webkit-scrollbar { width: 8px; height: 8px; }
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output::-webkit-scrollbar-track { background: transparent; }
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border: 2px solid transparent;
+ border-radius: 999px;
+ background-clip: padding-box;
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output:hover::-webkit-scrollbar-thumb {
+ background: var(--color-border-strong);
+ background-clip: padding-box;
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output[data-fade-top="true"][data-fade-bottom="true"] {
+ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 2rem, #000 calc(100% - 2rem), transparent);
+ mask-image: linear-gradient(to bottom, transparent, #000 2rem, #000 calc(100% - 2rem), transparent);
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output[data-fade-top="true"][data-fade-bottom="false"] {
+ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 2rem);
+ mask-image: linear-gradient(to bottom, transparent, #000 2rem);
+}
+.message.activity:is([data-kind="tool"], [data-kind="read"]) .terminal-output[data-fade-top="false"][data-fade-bottom="true"] {
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent);
+ mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent);
+}
+.terminal-output-action {
+ position: absolute;
+ top: 0;
+ right: 10px;
+}
+.terminal-output-wrap:hover .terminal-output-action,
+.terminal-output-action:focus-visible { opacity: 1; }
+.terminal-no-output {
+ color: var(--color-text-tertiary);
+ font: inherit;
+}
+.terminal-footer {
+ min-height: 26px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 2px 10px 4px;
+ color: var(--color-text-tertiary);
+ font-size: var(--font-size);
+ line-height: 20px;
+}
+.terminal-status { display: inline-flex; align-items: center; gap: 4px; }
+.terminal-footer[data-status="failed"], .terminal-footer[data-status="declined"] { color: var(--color-text-tertiary); }
+.terminal-footer[data-status="completed"] { color: var(--color-text-tertiary); }
+.terminal-status-icon {
+ width: 12px;
+ height: 12px;
+ flex: 0 0 12px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.2;
+}
+.terminal-output .ansi-bold { font-weight: 700; }
+.terminal-output .ansi-dim { opacity: .5; }
+.terminal-output .ansi-italic { font-style: italic; }
+.terminal-output .ansi-underline { text-decoration: underline; text-underline-offset: 2px; }
+.terminal-output .ansi-strikethrough { text-decoration: line-through; }
+.terminal-output .ansi-black-fg { color: var(--color-codex-terminal-ansi-black, #000); }
+.terminal-output .ansi-red-fg { color: var(--color-codex-terminal-ansi-red, #f66); }
+.terminal-output .ansi-green-fg { color: var(--color-codex-terminal-ansi-green, #94f494); }
+.terminal-output .ansi-yellow-fg { color: var(--color-codex-terminal-ansi-yellow, #f4f47b); }
+.terminal-output .ansi-blue-fg { color: var(--color-codex-terminal-ansi-blue, #9e9eff); }
+.terminal-output .ansi-magenta-fg { color: var(--color-codex-terminal-ansi-magenta, #db6bdb); }
+.terminal-output .ansi-cyan-fg { color: var(--color-codex-terminal-ansi-cyan, #81eeee); }
+.terminal-output .ansi-white-fg { color: var(--color-codex-terminal-ansi-white, #d6d6d6); }
+.terminal-output .ansi-bright-black-fg { color: var(--color-codex-terminal-ansi-bright-black, #6e6e6e); }
+.terminal-output .ansi-bright-red-fg { color: var(--color-codex-terminal-ansi-bright-red, #ffa8a8); }
+.terminal-output .ansi-bright-green-fg { color: var(--color-codex-terminal-ansi-bright-green, #0f0); }
+.terminal-output .ansi-bright-yellow-fg { color: var(--color-codex-terminal-ansi-bright-yellow, #ffffa8); }
+.terminal-output .ansi-bright-blue-fg { color: var(--color-codex-terminal-ansi-bright-blue, #9494ff); }
+.terminal-output .ansi-bright-magenta-fg { color: var(--color-codex-terminal-ansi-bright-magenta, #ffb3ff); }
+.terminal-output .ansi-bright-cyan-fg { color: var(--color-codex-terminal-ansi-bright-cyan, #adffff); }
+.terminal-output .ansi-bright-white-fg { color: var(--color-codex-terminal-ansi-bright-white, #fff); }
+.terminal-output .ansi-black-bg { background-color: var(--color-codex-terminal-ansi-black, #000); }
+.terminal-output .ansi-red-bg { background-color: var(--color-codex-terminal-ansi-red, #f66); }
+.terminal-output .ansi-green-bg { background-color: var(--color-codex-terminal-ansi-green, #94f494); }
+.terminal-output .ansi-yellow-bg { background-color: var(--color-codex-terminal-ansi-yellow, #f4f47b); }
+.terminal-output .ansi-blue-bg { background-color: var(--color-codex-terminal-ansi-blue, #9e9eff); }
+.terminal-output .ansi-magenta-bg { background-color: var(--color-codex-terminal-ansi-magenta, #db6bdb); }
+.terminal-output .ansi-cyan-bg { background-color: var(--color-codex-terminal-ansi-cyan, #81eeee); }
+.terminal-output .ansi-white-bg { background-color: var(--color-codex-terminal-ansi-white, #d6d6d6); }
+.terminal-output .ansi-bright-black-bg { background-color: var(--color-codex-terminal-ansi-bright-black, #6e6e6e); }
+.terminal-output .ansi-bright-red-bg { background-color: var(--color-codex-terminal-ansi-bright-red, #ffa8a8); }
+.terminal-output .ansi-bright-green-bg { background-color: var(--color-codex-terminal-ansi-bright-green, #0f0); }
+.terminal-output .ansi-bright-yellow-bg { background-color: var(--color-codex-terminal-ansi-bright-yellow, #ffffa8); }
+.terminal-output .ansi-bright-blue-bg { background-color: var(--color-codex-terminal-ansi-bright-blue, #9494ff); }
+.terminal-output .ansi-bright-magenta-bg { background-color: var(--color-codex-terminal-ansi-bright-magenta, #ffb3ff); }
+.terminal-output .ansi-bright-cyan-bg { background-color: var(--color-codex-terminal-ansi-bright-cyan, #adffff); }
+.terminal-output .ansi-bright-white-bg { background-color: var(--color-codex-terminal-ansi-bright-white, #fff); }
+.message.activity[data-kind="reasoning"] .details-body,
+.message.activity[data-kind="plan"] .details-body,
+.message.activity[data-kind="edit"] .details-body { max-height: 140px; }
+.diff-output {
+ margin: 5px 0 0;
+ padding: 7px 9px;
+ overflow: auto;
+ color: var(--color-text-secondary);
+ font: var(--code-size)/1.5 var(--font-mono);
+ white-space: pre;
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+}
+.diff-line { display: block; min-height: 1.5em; }
+.diff-line.added { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 10%, transparent); }
+.diff-line.removed { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 10%, transparent); }
+.diff-line.context { color: var(--color-text-tertiary); }
+
+.scroll-to-bottom {
+ position: absolute;
+ z-index: 12;
+ left: 50%;
+ right: auto;
+ bottom: calc(var(--thread-scroll-padding-bottom, 112px) + 4px);
+ width: 32px;
+ height: 32px;
+ min-height: 32px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-secondary);
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: 50%;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, .24);
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(-50%, 4px);
+ transition: opacity .12s ease, transform .12s ease, color .12s ease;
+}
+.scroll-to-bottom[data-visible="true"] { opacity: 1; pointer-events: auto; transform: translate(-50%, 0); }
+.scroll-to-bottom:hover { color: var(--color-text); background: var(--color-surface-hover); }
+.scroll-to-bottom svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.3; }
+.scroll-working-dots { display: none; align-items: center; gap: 2px; height: 12px; }
+.scroll-working-dots i { width: 3px; height: 3px; background: currentColor; border-radius: 50%; opacity: .28; animation: activity-dot 1.2s ease-in-out infinite; }
+.scroll-working-dots i:nth-child(2) { animation-delay: .16s; }
+.scroll-working-dots i:nth-child(3) { animation-delay: .32s; }
+.scroll-to-bottom[data-working="true"] svg { display: none; }
+.scroll-to-bottom[data-working="true"] .scroll-working-dots { display: inline-flex; }
+
+.live-activity {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ min-height: 28px;
+ gap: 7px;
+ padding: 3px 15px 6px;
+ color: var(--color-warning);
+ font-size: 11px;
+}
+.live-status {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.live-activity[hidden] { display: none; }
+.live-activity[data-activity="completed"] { color: var(--color-success); }
+.live-activity[data-activity="failed"], .live-activity[data-activity="interrupted"] { color: var(--color-danger); }
+.activity-spinner {
+ width: 11px;
+ height: 11px;
+ flex: 0 0 11px;
+ display: inline-block;
+ border: 1px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+}
+.live-activity[data-active="true"] .activity-spinner { animation: activity-spin .8s linear infinite; }
+.live-activity[data-active="false"] .activity-spinner { opacity: .45; }
+.activity-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.activity-dots { display: inline-flex; align-items: center; gap: 2px; height: 12px; }
+.activity-dots i {
+ width: 2px;
+ height: 2px;
+ background: currentColor;
+ border-radius: 50%;
+ opacity: .28;
+ animation: activity-dot 1.2s ease-in-out infinite;
+}
+.activity-dots i:nth-child(2) { animation-delay: .16s; }
+.activity-dots i:nth-child(3) { animation-delay: .32s; }
+.activity-elapsed { margin-left: auto; color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; }
+@keyframes activity-spin { to { transform: rotate(360deg); } }
+@keyframes activity-dot { 0%, 70%, 100% { opacity: .22; transform: translateY(0); } 35% { opacity: .9; transform: translateY(-2px); } }
+@keyframes thinking-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }
+
+.inline-requests {
+ position: relative;
+ z-index: 16;
+ flex: 0 0 auto;
+ max-height: min(42vh, 360px);
+ display: grid;
+ gap: 9px;
+ padding: 0 12px calc(var(--thread-scroll-padding-bottom, 112px) + 10px);
+ background: var(--color-background);
+ overflow: auto;
+}
+.inline-requests.empty { display: none; }
+.inline-requests .request {
+ padding: 12px 13px;
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 10px;
+ box-shadow: 0 4px 18px rgba(0, 0, 0, .16);
+}
+.request-title { align-items: center; display: flex; min-width: 0; gap: 7px; }
+.request-icon {
+ width: 18px;
+ height: 18px;
+ flex: 0 0 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-warning);
+ font-size: 11px;
+ background: color-mix(in srgb, var(--color-warning) 14%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-warning) 42%, transparent);
+ border-radius: 50%;
+}
+.request-method { min-width: 0; overflow-wrap: anywhere; color: var(--color-text); font-size: 12px; font-weight: 600; }
+.request-risk { padding: 2px 6px; color: var(--color-warning); font-size: 9px; border: 1px solid color-mix(in srgb, var(--color-warning) 42%, transparent); border-radius: 999px; white-space: nowrap; }
+.request-risk[data-risk="high"] { color: var(--color-danger); border-color: color-mix(in srgb, var(--color-danger) 50%, transparent); }
+.request-risk[data-risk="low"] { color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 45%, transparent); }
+.request-id { margin-left: auto; color: var(--color-text-tertiary); font: 10px var(--font-mono); }
+.request-summary { margin: 9px 0; color: var(--color-text-secondary); font-size: 11px; line-height: 1.5; white-space: pre-wrap; }
+.request-command, .request-json { max-width: 100%; margin: 8px 0; padding: 8px 9px; overflow: auto; color: var(--color-text); font: var(--code-size)/1.45 var(--font-mono); white-space: pre-wrap; word-break: break-word; background: var(--color-background); border: 1px solid var(--color-border); border-radius: 6px; }
+.request-questions { display: grid; gap: 8px; margin: 9px 0; }
+.request-question { display: grid; gap: 4px; color: var(--color-text); font-size: 11px; }
+.request-question input, .request-question select, .request-scope { min-height: 30px; padding: 5px 8px; color: var(--color-text); background: var(--color-background); border: 1px solid var(--color-border-strong); border-radius: 5px; }
+.request-scope-wrap { max-width: 220px; display: grid; gap: 4px; margin: 9px 0; color: var(--color-text-secondary); font-size: 10px; }
+.request-details { margin-top: 8px; border-top: 1px solid var(--color-border); }
+.request-details > summary { padding: 7px 0 2px; color: var(--color-text-secondary); font-size: 10px; cursor: pointer; }
+.request-details .request-json { margin-bottom: 7px; }
+.request-response { width: 100%; min-height: 58px; margin-top: 7px; padding: 7px 8px; resize: vertical; color: var(--color-text); font: var(--code-size)/1.4 var(--font-mono); background: var(--color-background); border: 1px solid var(--color-border-strong); border-radius: 5px; }
+.request-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border); }
+.request-actions button { min-height: 28px; padding: 4px 9px; font-size: 10px; }
+
+.composer {
+ position: absolute;
+ z-index: 15;
+ right: 10px;
+ bottom: 8px;
+ left: 16px;
+ pointer-events: none;
+ /* The transcript scrolls underneath this fixed composer. Keep a solid
+ reading band so expanded command output never bleeds through the input. */
+ padding-top: 5px;
+ background: var(--color-background);
+}
+.composer > .live-activity {
+ pointer-events: none;
+ width: min(100%, max-content);
+ min-height: 26px;
+ padding: 2px 9px 5px;
+ color: var(--color-text-secondary);
+ font-size: 11px;
+}
+.composer > .live-activity .activity-elapsed { margin-left: 4px; }
+.composer > .live-activity[data-activity="thinking"] .activity-spinner,
+.composer > .live-activity[data-activity="editing"] .activity-spinner,
+.composer > .live-activity[data-activity="running"] .activity-spinner { color: var(--color-success); }
+.subagents-panel {
+ pointer-events: auto;
+ max-height: min(24dvh, 150px);
+ margin: 0 8px 4px;
+ overflow: hidden;
+ color: var(--color-text-secondary);
+}
+.subagents-panel[hidden] { display: none; }
+.subagents-toggle {
+ width: 100%;
+ min-height: 24px;
+ padding: 1px 2px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--color-text-secondary);
+ text-align: left;
+ background: transparent;
+ border: 0;
+}
+.subagents-toggle:hover { color: var(--color-text); }
+.subagents-title { font-size: 10px; }
+.subagents-count { color: var(--color-text-tertiary); font-size: 10px; }
+.subagents-toggle svg { width: 12px; height: 12px; margin-left: 1px; transition: transform .18s cubic-bezier(.33, 1, .68, 1); }
+.subagents-toggle[aria-expanded="true"] svg { transform: rotate(90deg); }
+.subagents-list {
+ display: grid;
+ gap: 1px;
+ max-height: 112px;
+ overflow: auto;
+ scrollbar-width: thin;
+ transition: grid-template-rows .2s cubic-bezier(.33, 1, .68, 1), opacity .16s ease;
+}
+.subagents-panel[data-collapsed="true"] .subagents-list {
+ max-height: 0;
+ overflow: hidden;
+ opacity: 0;
+ pointer-events: none;
+}
+.subagent-section { min-width: 0; }
+.subagent-section-heading {
+ padding: 5px 2px 2px;
+ color: var(--color-text-tertiary);
+ font-size: 9px;
+ line-height: 14px;
+ letter-spacing: 0;
+}
+.subagent-empty {
+ padding: 4px 2px 6px;
+ color: var(--color-text-tertiary);
+ font-size: 10px;
+}
+.subagent-section-rows { display: grid; gap: 1px; }
+.subagent-more {
+ min-height: 24px;
+ margin-top: 3px;
+ padding: 2px 2px;
+ color: var(--color-text-tertiary);
+ font-size: 10px;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 4px;
+}
+.subagent-more:hover,
+.subagent-more:focus-visible {
+ color: var(--color-text-secondary);
+ background: var(--color-surface-hover);
+ outline: none;
+}
+.subagent-row {
+ width: 100%;
+ min-width: 0;
+ min-height: 24px;
+ padding: 2px;
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 6px;
+ color: inherit;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+ cursor: default;
+}
+.subagent-row:is(button):hover,
+.subagent-row:is(button):focus-visible {
+ color: var(--color-text);
+ background: var(--color-surface-hover);
+ outline: none;
+}
+.subagent-icon {
+ width: 14px;
+ height: 14px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-tertiary);
+}
+.subagent-icon::before { display: none; }
+.subagent-icon svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.15; }
+.subagent-row[data-status="working"] .subagent-icon { color: var(--color-success); }
+.subagent-row[data-status="failed"] .subagent-icon { color: var(--color-danger); }
+.subagent-diff-stats { color: var(--color-text-tertiary); font-size: 9px; font-variant-numeric: tabular-nums; }
+.subagent-copy { min-width: 0; display: block; }
+.subagent-name { overflow: hidden; color: var(--color-text); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.subagent-objective, .subagent-elapsed { display: none; }
+.subagent-status { display: inline-flex; align-items: center; gap: 4px; color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; }
+.subagent-elapsed { min-width: 28px; color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; text-align: right; }
+.subagent-status-text { white-space: nowrap; }
+.subagent-focus { animation: subagent-focus .9s ease-out; }
+@keyframes subagent-focus { 0% { background: color-mix(in srgb, var(--color-info) 24%, transparent); } 100% { background: transparent; } }
+.subagent-body {
+ display: grid;
+ gap: 7px;
+ padding: 2px 0 4px;
+ color: var(--color-text-secondary);
+}
+.subagent-prompt { min-width: 0; font-size: 11px; line-height: 1.5; }
+.subagent-prompt p { margin: 0; }
+.subagent-action-meta { display: flex; align-items: center; gap: 7px; color: var(--color-text-tertiary); font-size: 10px; }
+.subagent-model { padding: 1px 5px; color: var(--color-text-secondary); background: var(--color-surface-hover); border-radius: 4px; font-family: var(--font-mono); }
+.subagent-action-rows { display: grid; gap: 2px; padding-top: 2px; border-top: 1px solid var(--color-border); }
+.subagent-action-row {
+ min-width: 0;
+ padding: 4px 5px;
+ display: grid;
+ grid-template-columns: 14px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 5px;
+ color: var(--color-text-secondary);
+ font-size: 10px;
+ border-radius: 4px;
+}
+.subagent-action-icon { width: 8px; height: 8px; border: 1px solid currentColor; border-radius: 50%; opacity: .65; }
+.subagent-action-row[data-status="working"] .subagent-action-icon { color: var(--color-success); border-right-color: transparent; animation: activity-spin .8s linear infinite; }
+.subagent-action-row[data-status="failed"] .subagent-action-icon { color: var(--color-danger); }
+.subagent-action-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.subagent-action-status { color: var(--color-text-tertiary); white-space: nowrap; }
+.subagent-action-note { grid-column: 2 / -1; overflow: hidden; color: var(--color-text-tertiary); text-overflow: ellipsis; white-space: nowrap; }
+.composer-surface {
+ pointer-events: auto;
+ padding: 9px 10px 7px;
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 20px;
+ box-shadow: var(--composer-shadow);
+ backdrop-filter: blur(16px);
+}
+.composer-surface:focus-within { border-color: color-mix(in srgb, var(--color-info) 68%, var(--color-border-strong)); }
+.composer-editor {
+ min-height: 40px;
+ max-height: 25dvh;
+ overflow: auto;
+ padding: 3px 2px;
+ color: var(--color-text);
+ line-height: 1.5;
+ outline: none;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.composer-editor:empty::before { content: attr(data-placeholder); color: var(--color-text-tertiary); pointer-events: none; }
+.composer-footer { align-items: center; display: flex; justify-content: space-between; gap: 8px; padding-top: 4px; }
+.composer-hint { position: relative; align-items: center; display: flex; min-width: 0; gap: 7px; color: var(--color-text-secondary); }
+.composer-icon-button {
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-secondary);
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+}
+.composer-icon-button:hover, .composer-icon-button[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); }
+.composer-icon-button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.3; }
+.permission-chip {
+ width: auto;
+ min-width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0 4px;
+ align-items: center;
+ display: inline-flex;
+ justify-content: flex-start;
+ gap: 4px;
+ color: var(--color-warning);
+ font-size: 10px;
+ white-space: nowrap;
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+}
+.permission-chip:hover, .permission-chip[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); }
+.permission-chip[hidden], .model-picker-button[hidden], .usage-picker[hidden] { display: none; }
+.permission-chip svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.1; }
+.permission-chip #permissionLabel {
+ min-width: 0;
+ max-width: 96px;
+ overflow: hidden;
+ display: inline-block;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.permission-chip .permission-chevron { display: inline-block; flex: 0 0 14px; }
+.composer-popover {
+ position: absolute;
+ z-index: 50;
+ min-width: 194px;
+ padding: 5px;
+ color: var(--color-text);
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 8px;
+ box-shadow: 0 10px 28px rgba(0, 0, 0, .34);
+}
+.composer-popover[hidden] { display: none; }
+.composer-popover-heading {
+ padding: 5px 8px 4px;
+ color: var(--color-text-tertiary);
+ font-size: 9px;
+ line-height: 14px;
+}
+.composer-plus-menu { left: 0; bottom: 31px; }
+.permission-menu { left: 31px; bottom: 34px; width: min(280px, calc(100vw - 28px)); }
+.permission-confirm {
+ position: absolute;
+ z-index: 60;
+ left: 31px;
+ bottom: 34px;
+ width: min(300px, calc(100vw - 28px));
+ padding: 12px;
+ color: var(--color-text);
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 9px;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, .42);
+}
+.permission-confirm[hidden] { display: none; }
+.permission-confirm-title { font-size: 12px; font-weight: 600; }
+.permission-confirm p { margin: 7px 0 11px; color: var(--color-text-secondary); font-size: 10px; line-height: 1.5; }
+.permission-confirm-actions { display: flex; justify-content: flex-end; gap: 6px; }
+.permission-confirm-actions button { min-height: 27px; padding: 4px 10px; color: var(--color-text-secondary); background: transparent; border: 1px solid var(--color-border); border-radius: 5px; font-size: 10px; }
+.permission-confirm-actions button:hover { color: var(--color-text); background: var(--color-surface-hover); }
+.permission-confirm-actions button.primary { color: var(--color-background); background: var(--color-warning); border-color: var(--color-warning); }
+.composer-popover > button {
+ width: 100%;
+ min-height: 30px;
+ padding: 5px 8px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--color-text-secondary);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+}
+.composer-popover > button:hover,
+.composer-popover > button[aria-checked="true"] { color: var(--color-text); background: var(--color-surface-hover); }
+.permission-menu > button > span { min-width: 0; font-size: 11px; }
+.permission-menu > button > small { min-width: 0; overflow: hidden; color: var(--color-text-tertiary); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.approval-heading { margin-top: 4px; border-top: 1px solid var(--color-border); }
+.usage-picker { position: relative; }
+.usage-button {
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+ display: inline-grid;
+ place-items: center;
+ color: var(--color-text-tertiary);
+ font-size: 9px;
+ font-variant-numeric: tabular-nums;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+}
+.usage-button:hover, .usage-button[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); }
+.usage-ring {
+ --usage-percent: 0%;
+ position: relative;
+ width: 16px;
+ height: 16px;
+ display: inline-block;
+ background: conic-gradient(var(--color-info) var(--usage-percent), color-mix(in srgb, var(--color-text-secondary) 24%, transparent) 0);
+ border-radius: 50%;
+ transform: rotate(-90deg);
+}
+.usage-ring[data-level="warning"] { background: conic-gradient(var(--color-warning) var(--usage-percent), color-mix(in srgb, var(--color-text-secondary) 24%, transparent) 0); }
+.usage-ring[data-level="critical"] { background: conic-gradient(var(--color-danger) var(--usage-percent), color-mix(in srgb, var(--color-text-secondary) 24%, transparent) 0); }
+.usage-ring::after {
+ position: absolute;
+ inset: 3px;
+ content: "";
+ background: var(--color-surface-secondary);
+ border-radius: 50%;
+}
+.usage-ring > span {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+}
+.usage-menu { right: 0; bottom: 31px; width: min(245px, calc(100vw - 28px)); }
+.usage-summary { padding: 4px 8px 7px; color: var(--color-text-secondary); font-size: 11px; }
+.usage-meter { height: 4px; margin: 0 8px 7px; overflow: hidden; background: var(--color-border); border-radius: 999px; }
+.usage-meter span { display: block; width: 0; height: 100%; background: var(--color-info); border-radius: inherit; transition: width .2s ease; }
+.usage-details { padding: 2px 8px 5px; color: var(--color-text-tertiary); font: 10px/1.45 var(--font-mono); white-space: pre-wrap; }
+.composer-actions { align-items: center; display: flex; flex: 0 0 auto; gap: 5px; }
+.model-picker { position: relative; }
+.model-picker-button {
+ min-height: 26px;
+ padding: 2px 5px 2px 7px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-text-secondary);
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+}
+.model-picker-button:hover, .model-picker-button[aria-expanded="true"] { color: var(--color-text); background: var(--color-surface-hover); }
+.model-picker-button svg { width: 12px; height: 12px; }
+.model-label { max-width: 110px; overflow: hidden; color: inherit; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.model-effort-label { color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; }
+.model-menu {
+ position: absolute;
+ z-index: 40;
+ right: -4px;
+ bottom: 34px;
+ width: min(280px, calc(100vw - 28px));
+ max-height: min(58dvh, 440px);
+ padding: 7px;
+ overflow: auto;
+ color: var(--color-text);
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 10px;
+ box-shadow: 0 10px 28px rgba(0, 0, 0, .32);
+}
+.model-menu[hidden] { display: none; }
+.model-power-view { padding: 5px 4px 7px; }
+.model-power-heading,
+.model-advanced-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 28px;
+ color: var(--color-text-secondary);
+ font-size: 11px;
+}
+.model-advanced-toggle,
+.model-advanced-back {
+ min-height: 25px;
+ padding: 3px 7px;
+ color: var(--color-text-tertiary);
+ font-size: 10px;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+}
+.model-advanced-toggle:hover,
+.model-advanced-toggle:focus-visible,
+.model-advanced-back:hover,
+.model-advanced-back:focus-visible { color: var(--color-text); background: var(--color-surface-hover); outline: none; }
+.model-power-control { display: grid; grid-template-columns: auto minmax(82px, 1fr) auto; align-items: center; gap: 7px; min-height: 34px; }
+.model-power-label { color: var(--color-text-tertiary); font-size: 10px; white-space: nowrap; }
+.model-power-slider {
+ width: 100%;
+ height: 24px;
+ margin: 0;
+ appearance: none;
+ background: color-mix(in srgb, var(--color-text-secondary) 10%, transparent);
+ border-radius: 12px;
+ outline: none;
+}
+.model-power-slider::-webkit-slider-runnable-track { height: 24px; background: transparent; border-radius: 12px; }
+.model-power-slider::-webkit-slider-thumb {
+ width: 28px;
+ height: 28px;
+ margin-top: -2px;
+ appearance: none;
+ background: var(--color-text);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 50%;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
+}
+.model-power-slider::-moz-range-track { height: 24px; background: transparent; border-radius: 12px; }
+.model-power-slider::-moz-range-thumb {
+ width: 26px;
+ height: 26px;
+ background: var(--color-text);
+ border: 1px solid var(--color-border-strong);
+ border-radius: 50%;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
+}
+.model-power-slider:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-info) 65%, transparent); }
+.model-power-slider:disabled { opacity: .45; }
+.model-power-value { min-height: 16px; color: var(--color-text-tertiary); font-size: 10px; text-align: center; }
+.model-advanced-view { padding: 2px 0 0; }
+.model-advanced-toolbar { margin: 0 -1px 4px; padding: 0 3px 4px; border-bottom: 1px solid var(--color-border); }
+.model-advanced-back { padding-inline: 5px; font-size: 17px; line-height: 1; }
+.model-menu-heading { padding: 4px 8px; color: var(--color-text-tertiary); font-size: 10px; }
+.model-options { display: grid; gap: 1px; }
+.model-option {
+ width: 100%;
+ min-height: 40px;
+ padding: 6px 8px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 2px 8px;
+ color: var(--color-text);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+}
+.model-option:hover, .model-option[aria-selected="true"] { background: var(--color-surface-hover); }
+.model-option-name { min-width: 0; overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.model-option-description { grid-column: 1 / -1; overflow: hidden; color: var(--color-text-tertiary); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.model-option-check { color: var(--color-success); font-size: 11px; opacity: 0; }
+.model-option[aria-selected="true"] .model-option-check { opacity: 1; }
+.effort-heading { margin-top: 4px; border-top: 1px solid var(--color-border); padding-top: 7px; }
+.effort-options { display: grid; gap: 1px; padding: 1px 3px 4px; }
+.effort-option {
+ width: 100%;
+ min-width: 0;
+ min-height: 28px;
+ padding: 4px 7px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: var(--color-text-secondary);
+ font-size: 10px;
+ background: transparent;
+ border: 0;
+ border-radius: 5px;
+}
+.effort-option:hover, .effort-option[aria-selected="true"] { color: var(--color-text); background: var(--color-surface-hover); }
+.effort-option-check { color: var(--color-success); opacity: 0; }
+.effort-option[aria-selected="true"] .effort-option-check { opacity: 1; }
+.model-picker[data-pending="true"] .model-picker-button { opacity: .62; }
+.compact-action { width: 28px; height: 28px; min-height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; }
+.compact-action svg { width: 15px; height: 15px; }
+.interrupt-action { display: none; color: var(--color-danger); background: transparent; border: 1px solid color-mix(in srgb, var(--color-danger) 45%, transparent); }
+.interrupt-action:hover:not(:disabled) { background: color-mix(in srgb, var(--color-danger) 12%, transparent); }
+.steer-action { display: none; }
+.steer-action svg { stroke: currentColor; }
+.send-button {
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #1a1a1a;
+ background: #d4d4d4;
+ border: 0;
+ border-radius: 50%;
+}
+.send-button:hover:not(:disabled) { background: #fff; }
+.send-button svg { width: 16px; height: 16px; stroke-width: 1.45; }
+.mode-row {
+ display: flex;
+ pointer-events: auto;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 26px;
+ gap: 8px;
+ padding: 5px 8px 0;
+ color: var(--color-text-secondary);
+ font-size: 10px;
+ background: var(--color-background);
+}
+.connection-mode-label { min-width: 0; display: inline-flex; align-items: center; gap: 5px; }
+.connection-mode-label > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.mode-icon { width: 15px; height: 15px; }
+.control-mode-switch {
+ width: 82px;
+ height: 24px;
+ padding: 2px;
+ display: inline-grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ flex: 0 0 82px;
+ gap: 1px;
+ background: var(--color-surface-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: 5px;
+}
+.control-mode-switch button {
+ min-width: 0;
+ min-height: 18px;
+ padding: 0 4px;
+ overflow: hidden;
+ color: var(--color-text-tertiary);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ background: transparent;
+ border: 0;
+ border-radius: 3px;
+ font-size: 9px;
+ line-height: 18px;
+}
+.control-mode-switch button:hover:not(:disabled) { color: var(--color-text); background: var(--color-surface-hover); }
+.control-mode-switch button[aria-pressed="true"] {
+ color: var(--color-text);
+ background: var(--color-background);
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-border-strong) 72%, transparent);
+}
+.control-mode-switch button:focus-visible { outline: 1px solid var(--color-info); outline-offset: -1px; }
+.control-mode-switch button:disabled { cursor: default; opacity: .46; }
+.control-mode-switch button[aria-pressed="true"]:disabled { opacity: 1; }
+.control-mode-switch[data-switching="true"] button[data-pending="true"] { color: var(--color-info); opacity: 1; }
+.header-back-button[hidden],
+.header-history-button[hidden],
+.new-session-button[hidden],
+.panel-menu button[hidden] { display: none; }
+.thread-picker-button:disabled { cursor: default; opacity: 1; }
+.thread-picker-button:disabled:hover { opacity: 1; }
+body.turn-active .interrupt-action { display: inline-flex; }
+body.turn-active .steer-action { display: inline-flex; }
+body.turn-active #startTurnButton { display: none; }
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.compatibility-state {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+}
+
+@media (max-width: 640px) {
+ .codex-panel { border-inline: 0; }
+ .chat-scroll { padding-inline: 12px; }
+ .composer { right: 8px; bottom: 6px; left: 8px; }
+ .composer-surface { border-radius: 15px; }
+ .message.user .message-content { max-width: 77%; }
+ .request-id { display: none; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .live-activity[data-active="true"] .activity-spinner { animation: none; }
+ .message.activity[data-status="inProgress"][data-kind="reasoning"] .message-details > summary { animation: none; }
+ .message-details > summary::after { transition: none; }
+ .activity-dots i, .scroll-working-dots i, .subagent-row[data-status="working"] .subagent-icon::before { animation: none; }
+}
diff --git a/aether-vscodex/relay/server.js b/aether-vscodex/relay/server.js
new file mode 100644
index 000000000..d91c26d53
--- /dev/null
+++ b/aether-vscodex/relay/server.js
@@ -0,0 +1,2613 @@
+"use strict";
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const http = require("node:http");
+const net = require("node:net");
+const path = require("node:path");
+const { spawn } = require("node:child_process");
+const { URL } = require("node:url");
+const { WebSocket, WebSocketServer } = require("ws");
+
+const PACKAGE_ROOT = path.resolve(__dirname, "..");
+const VUE_PUBLIC_ROOT = path.join(PACKAGE_ROOT, "web", "dist");
+const PUBLIC_ROOT = fs.existsSync(path.join(VUE_PUBLIC_ROOT, "index.html"))
+ ? VUE_PUBLIC_ROOT
+ : path.join(PACKAGE_ROOT, "public");
+const MAX_JSON_BODY = 1024 * 1024;
+// Complete attached-session snapshots include the structured message/tool
+// projection and can easily exceed 256 KiB. This remains bounded to protect
+// the local relay while allowing long Codex conversations to hydrate.
+const MAX_WS_PAYLOAD = 16 * 1024 * 1024;
+const DEFAULT_EVENT_LIMIT = 2_000;
+// Transcript state is represented once in the authoritative control snapshot.
+// Keep replay and socket buffering smaller than an unbounded count of maximum
+// sized frames so one long conversation cannot amplify into gigabytes.
+const DEFAULT_EVENT_BYTE_LIMIT = 16 * 1024 * 1024;
+const DEFAULT_REPLAY_BYTE_LIMIT = 2 * 1024 * 1024;
+const DEFAULT_CLIENT_BUFFERED_BYTE_LIMIT = MAX_WS_PAYLOAD + 2 * 1024 * 1024;
+const MAX_REPLAY_TEXT_BYTES = 64 * 1024;
+const MUTATING_METHODS = new Set([
+ "control/mode/set",
+ "thread/start",
+ "session/new",
+ "thread/settings/update",
+ "session/select",
+ "turn/start",
+ "turn/steer",
+ "turn/interrupt",
+]);
+const ALLOWED_METHODS = new Set(["initialize", "control/mode/get", "session/list", ...MUTATING_METHODS]);
+const SERVER_REQUEST_METHODS = new Set([
+ "item/commandExecution/requestApproval",
+ "item/fileChange/requestApproval",
+ "item/permissions/requestApproval",
+ "item/tool/requestUserInput",
+ "mcpServer/elicitation/request",
+ "applyPatchApproval",
+ "execCommandApproval",
+]);
+const REMOTE_RESPONSE_METHODS = new Set([
+ "approval.respond",
+ "input.respond",
+ "server.request.respond",
+]);
+const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
+
+function randomToken() {
+ return crypto.randomBytes(24).toString("base64url");
+}
+
+function randomId(prefix) {
+ return `${prefix}_${crypto.randomBytes(9).toString("base64url")}`;
+}
+
+// JSON-RPC treats numeric and string ids as distinct values. Keep that
+// distinction in in-memory maps while retaining a small compatibility bridge
+// for older browser clients that stringify numeric ids before responding.
+function jsonRpcIdKey(id) {
+ if (typeof id === "number") {
+ return `number:${Object.is(id, -0) ? "-0" : String(id)}`;
+ }
+ if (typeof id === "string") return `string:${id}`;
+ if (id === null) return "null:";
+ return `${typeof id}:${String(id)}`;
+}
+
+function isJsonRpcId(id) {
+ return typeof id === "string" || typeof id === "number";
+}
+
+function findTypedMapKey(map, id, valueId = (value) => value?.appId, allowLegacyStringified = false) {
+ const exact = jsonRpcIdKey(id);
+ if (map.has(exact)) return exact;
+ // Before protocol v1, the browser console sent every request id as text.
+ // Allow that form only when it maps to one unambiguous pending id. If both
+ // `1` and `"1"` are pending, the exact typed key above wins and no cross-talk
+ // is possible.
+ if (!allowLegacyStringified || !isJsonRpcId(id)) return exact;
+ const text = String(id);
+ const candidates = [];
+ for (const [key, value] of map) {
+ const candidateId = valueId(value);
+ if (isJsonRpcId(candidateId) && String(candidateId) === text) candidates.push(key);
+ }
+ return candidates.length === 1 ? candidates[0] : exact;
+}
+
+function sameJsonRpcId(left, right) {
+ return typeof left === typeof right && String(left) === String(right)
+ && (typeof left === "string" || typeof left === "number");
+}
+
+function responseCommandId(requestId, pendingServerRequests, pendingHostCommands) {
+ for (const [commandId, pending] of pendingHostCommands) {
+ if (pending.kind === "server-response"
+ && (sameJsonRpcId(pending.requestId, requestId) || sameJsonRpcId(pending.responseRequestId, requestId))) return commandId;
+ }
+ const base = `response-${String(requestId)}`;
+ let oppositeKey;
+ if (typeof requestId === "number") {
+ oppositeKey = jsonRpcIdKey(String(requestId));
+ } else if (typeof requestId === "string") {
+ const numeric = Number(requestId);
+ if (Number.isFinite(numeric)) oppositeKey = jsonRpcIdKey(numeric);
+ }
+ if (oppositeKey && pendingServerRequests.has(oppositeKey)) return `${base}-${typeof requestId}`;
+
+ const typed = `response-${jsonRpcIdKey(requestId)}`;
+ const existingBase = pendingHostCommands.get(base);
+ if (!existingBase || sameJsonRpcId(existingBase.requestId, requestId)) return base;
+ const existingTyped = pendingHostCommands.get(typed);
+ if (!existingTyped || sameJsonRpcId(existingTyped.requestId, requestId)) return typed;
+ // This is only reachable if a caller has manually occupied both stable
+ // names. Keep the command id deterministic and bounded while avoiding an
+ // accidental overwrite.
+ return `${typed}-${crypto.createHash("sha256").update(String(requestId)).digest("hex").slice(0, 8)}`;
+}
+
+function secureEqual(left, right) {
+ const a = Buffer.from(String(left || ""));
+ const b = Buffer.from(String(right || ""));
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+function jsonResponse(response, statusCode, body, extraHeaders = {}) {
+ const payload = Buffer.from(JSON.stringify(body));
+ response.writeHead(statusCode, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Length": payload.length,
+ "Cache-Control": "no-store",
+ ...extraHeaders,
+ });
+ response.end(payload);
+}
+
+function readJson(request) {
+ return new Promise((resolve, reject) => {
+ let size = 0;
+ const chunks = [];
+ request.on("data", (chunk) => {
+ size += chunk.length;
+ if (size > MAX_JSON_BODY) {
+ reject(Object.assign(new Error("request body is too large"), { statusCode: 413 }));
+ request.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ request.on("end", () => {
+ try {
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"));
+ } catch {
+ reject(Object.assign(new Error("invalid JSON body"), { statusCode: 400 }));
+ }
+ });
+ request.on("error", reject);
+ });
+}
+
+function redactString(value) {
+ return String(value)
+ .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, "[REDACTED_API_KEY]")
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+\/-]{12,}/gi, "$1[REDACTED]")
+ .replace(/\b(gh[pousr]_[A-Za-z0-9]{20,})\b/g, "[REDACTED_GITHUB_TOKEN]")
+ .replace(/([?&](?:token|key|secret)=)[^&\s]+/gi, "$1[REDACTED]");
+}
+
+function redact(value, depth = 0) {
+ if (depth > 8) return "[TRUNCATED]";
+ if (typeof value === "string") return redactString(value);
+ if (Array.isArray(value)) return value.map((entry) => redact(entry, depth + 1));
+ if (value && typeof value === "object") {
+ const result = {};
+ for (const [key, entry] of Object.entries(value)) {
+ if (/token|authorization|cookie|private.?key|secret/i.test(key)) {
+ result[key] = "[REDACTED]";
+ } else {
+ result[key] = redact(entry, depth + 1);
+ }
+ }
+ return result;
+ }
+ return value;
+}
+
+function applyStructuredMessagesPatch(current, patch) {
+ if (!Array.isArray(current) || !patch || typeof patch !== "object" || Array.isArray(patch)) return null;
+ const start = Number(patch.start);
+ const deleteCount = Number(patch.deleteCount);
+ if (!Number.isInteger(start) || start < 0 || start > current.length
+ || !Number.isInteger(deleteCount) || deleteCount < 0 || start + deleteCount > current.length
+ || !Array.isArray(patch.messages)) return null;
+ return [
+ ...current.slice(0, start),
+ ...patch.messages,
+ ...current.slice(start + deleteCount),
+ ];
+}
+
+function positiveByteLimit(value, fallback) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
+}
+
+function jsonByteLength(value) {
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
+}
+
+function boundedReplayText(value) {
+ const encoded = Buffer.from(String(value), "utf8");
+ if (encoded.length <= MAX_REPLAY_TEXT_BYTES) return String(value);
+ // A cut through a multi-byte code point can add one replacement character,
+ // which is harmless for this best-effort replay hint. The following control
+ // snapshot carries the exact authoritative transcript.
+ return encoded.subarray(encoded.length - MAX_REPLAY_TEXT_BYTES).toString("utf8");
+}
+
+// Every subscriber receives an authoritative control snapshot after replay.
+// Keep transcript-bearing live events rich, but store only their lightweight
+// form in the replay ring so streaming a long session cannot retain hundreds
+// of duplicate full-history projections.
+function compactTranscriptEventForReplay(event) {
+ if (!event || !["session.snapshot", "output.snapshot", "output.chunk"].includes(event.type)) return event;
+ const source = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
+ ? event.payload
+ : {};
+ // Use an allow-list rather than deleting known large fields. In particular,
+ // current attach adapters send `messagesPatch` instead of `messages`, and a
+ // suffix replacement can itself be nearly as large as the full transcript.
+ const payload = { projectionInControlSnapshot: true };
+ for (const key of [
+ "threadId",
+ "turnId",
+ "requestId",
+ "source",
+ "sourceSeq",
+ "stream",
+ "encoding",
+ "state",
+ "structureChanged",
+ ]) {
+ const value = source[key];
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
+ payload[key] = value;
+ }
+ }
+ if (event.type === "output.chunk" && typeof source.text === "string" && source.text) {
+ payload.text = boundedReplayText(source.text);
+ }
+ return { ...event, payload };
+}
+
+// Token usage is telemetry, not an authentication credential. The generic
+// redactor intentionally treats any key containing "token" as sensitive, so
+// preserve only the numeric usage projection after redacting the rest of a
+// session metadata envelope.
+const SAFE_USAGE_FIELDS = [
+ "totalTokens",
+ "total_tokens",
+ "inputTokens",
+ "input_tokens",
+ "cachedInputTokens",
+ "cached_input_tokens",
+ "cacheWriteInputTokens",
+ "cache_write_input_tokens",
+ "outputTokens",
+ "output_tokens",
+ "reasoningOutputTokens",
+ "reasoning_output_tokens",
+];
+
+function safeUsageNumber(value) {
+ if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined;
+ if (typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value.trim())) {
+ const number = Number(value);
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
+ }
+ return undefined;
+}
+
+function safeUsageBreakdown(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
+ const result = {};
+ for (const field of SAFE_USAGE_FIELDS) {
+ const number = safeUsageNumber(value[field]);
+ if (number !== undefined) result[field.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())] = number;
+ }
+ return Object.keys(result).length ? result : undefined;
+}
+
+function safeTokenUsage(value) {
+ if (value === null) return null;
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
+ const source = value.info && typeof value.info === "object" ? value.info
+ : value.tokenUsage && typeof value.tokenUsage === "object" ? value.tokenUsage
+ : value.token_usage && typeof value.token_usage === "object" ? value.token_usage : value;
+ const result = {};
+ const total = safeUsageBreakdown(source.total ?? source.total_token_usage ?? source.totalTokenUsage);
+ const last = safeUsageBreakdown(source.last ?? source.last_token_usage ?? source.lastTokenUsage);
+ const context = safeUsageNumber(source.modelContextWindow ?? source.model_context_window ?? source.contextWindow ?? source.context_window);
+ if (total) result.total = total;
+ if (last) result.last = last;
+ if (context !== undefined) result.modelContextWindow = context;
+ return Object.keys(result).length ? result : undefined;
+}
+
+function redactSessionMetadata(value) {
+ const redacted = redact(value);
+ if (!redacted || typeof redacted !== "object" || Array.isArray(redacted) || !value || typeof value !== "object") return redacted;
+ for (const key of ["tokenUsage", "latestTokenUsageInfo"]) {
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
+ const usage = safeTokenUsage(value[key]);
+ if (usage !== undefined) redacted[key] = usage;
+ }
+ return redacted;
+}
+
+function normalizeError(error) {
+ return {
+ code: error && error.code ? String(error.code) : "relay_error",
+ message: redactString(error && error.message ? error.message : String(error)),
+ retryable: Boolean(error && error.retryable),
+ };
+}
+
+function parsePort(value, fallback) {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed >= 0 && parsed < 65536 ? parsed : fallback;
+}
+
+/** Parse an optional auth switch without treating an invalid value as false. */
+function parseAuthRequired(value) {
+ if (typeof value === "boolean") return value;
+ if (value === 1) return true;
+ if (value === 0) return false;
+ if (typeof value !== "string" || value.trim() === "") return undefined;
+ const normalized = value.trim().toLowerCase();
+ if (["1", "true", "yes", "on", "required", "enabled"].includes(normalized)) return true;
+ if (["0", "false", "no", "off", "none", "disabled", "local"].includes(normalized)) return false;
+ return undefined;
+}
+
+function hasConfiguredToken(value) {
+ return typeof value === "string" && value.length > 0;
+}
+
+/** Whether the configured listen address is restricted to this machine. */
+function isLoopbackIPv4(value) {
+ if (net.isIP(value) !== 4) return false;
+ const octets = value.split(".").map(Number);
+ return octets.length === 4 && octets[0] === 127;
+}
+
+function isLoopbackHost(host) {
+ const normalized = String(host || "").trim().toLowerCase().replace(/^\[|\]$/g, "");
+ if (normalized === "localhost" || normalized === "::1") return true;
+ if (isLoopbackIPv4(normalized)) return true;
+ return normalized.startsWith("::ffff:") && isLoopbackIPv4(normalized.slice("::ffff:".length));
+}
+
+/** Node reports IPv4 loopback peers as both 127.x and ::ffff:127.x. */
+function isLoopbackAddress(address) {
+ const normalized = String(address || "").trim().toLowerCase();
+ if (normalized === "::1") return true;
+ if (isLoopbackIPv4(normalized)) return true;
+ return normalized.startsWith("::ffff:") && isLoopbackIPv4(normalized.slice("::ffff:".length));
+}
+
+function isLoopbackRequestHost(request) {
+ const rawHost = String(request.headers.host || "").trim();
+ if (!rawHost) return false;
+ try {
+ return isLoopbackHost(new URL(`http://${rawHost}`).hostname);
+ } catch {
+ return false;
+ }
+}
+
+/** Allow browser writes only from the relay's own origin; CLI requests omit Origin. */
+function isAllowedHttpOrigin(request) {
+ const origin = request.headers.origin;
+ if (!origin) return true;
+ const requestHost = String(request.headers.host || "").trim().toLowerCase();
+ if (!requestHost) return false;
+ try {
+ return new URL(origin).host.toLowerCase() === requestHost;
+ } catch {
+ return false;
+ }
+}
+
+function contentType(filePath) {
+ const extension = path.extname(filePath).toLowerCase();
+ return (
+ {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ }[extension] || "application/octet-stream"
+ );
+}
+
+function outputText(method, params) {
+ if (!params || typeof params !== "object") return "";
+ const candidates = [params.delta, params.text, params.output, params.chunk, params.message];
+ if (params.item && typeof params.item === "object") {
+ candidates.push(params.item.text, params.item.content);
+ }
+ const text = candidates.find((candidate) => typeof candidate === "string");
+ if (text) return redactString(text);
+ if (/outputDelta|agentMessage\/delta|plan\/delta|reasoning\/.+Delta/.test(method)) {
+ return redactString(JSON.stringify(params));
+ }
+ return "";
+}
+
+function eventTypeForAppMessage(message) {
+ if (message.id !== undefined && message.method) {
+ if (message.method === "item/tool/requestUserInput" || message.method === "mcpServer/elicitation/request") {
+ return "input.requested";
+ }
+ if (SERVER_REQUEST_METHODS.has(message.method)) return "approval.requested";
+ return "server.requested";
+ }
+ if (message.id !== undefined) return "app.response";
+ const method = message.method || "unknown";
+ if (/outputDelta|agentMessage\/delta|plan\/delta|reasoning\/.+Delta/.test(method)) return "output.delta";
+ if (method === "turn/started") return "turn.started";
+ if (method === "turn/completed") return "turn.completed";
+ if (method === "thread/started") return "thread.started";
+ if (method === "error") return "app.error";
+ return "app.notification";
+}
+
+function approvalDecisionForResult(result) {
+ if (result && typeof result === "object" && !Array.isArray(result)) {
+ const decision = result.decision;
+ if (typeof decision === "string") {
+ if (["acceptForSession", "accept", "approved", "approved_for_session", "approved_mcp_policy_amendment"].includes(decision)) return "allow";
+ if (["cancel", "abort"].includes(decision)) return "cancel";
+ if (["decline", "denied", "timed_out"].includes(decision)) return "deny";
+ }
+ // App-server v2 encodes policy amendments as tagged objects. Require one
+ // known tag with its documented shape; unknown or mixed objects fail
+ // closed instead of being interpreted as an approval.
+ const decisionKind = approvalDecisionKind(decision);
+ if (decisionKind) return decisionKind;
+ }
+ if (result && typeof result === "object" && !Array.isArray(result)) {
+ if (result.action === "accept") return "allow";
+ if (result.action === "cancel") return "cancel";
+ if (result.action === "decline") return "deny";
+ if (result.permissions && typeof result.permissions === "object") {
+ return Object.keys(result.permissions).length ? "allow" : "deny";
+ }
+ }
+ // A custom response is still sent to the bridge; this value is only the
+ // local policy hint used by RelayHost when it needs a canonical decision.
+ return "deny";
+}
+
+function approvalDecisionKind(value) {
+ if (typeof value === "string") {
+ if (["allow", "accept", "acceptForSession", "approved", "approved_for_session", "approved_mcp_policy_amendment"].includes(value)) return "allow";
+ if (["deny", "decline", "denied", "timed_out"].includes(value)) return "deny";
+ if (["cancel", "abort"].includes(value)) return "cancel";
+ return undefined;
+ }
+ const key = knownDecisionObjectKey(value);
+ if (key) return key === "denied" ? "deny" : "allow";
+ return undefined;
+}
+
+function responseErrorMessage(error) {
+ if (typeof error === "string") return redactString(error).slice(0, 1_000);
+ if (error && typeof error === "object" && typeof error.message === "string") return redactString(error.message).slice(0, 1_000);
+ return "remote response rejected";
+}
+
+function defaultServerResponse(method, reason = "request timed out") {
+ if (method === "item/permissions/requestApproval") return { permissions: {}, scope: "turn" };
+ if (method === "item/tool/requestUserInput") return { answers: {} };
+ if (method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null };
+ if (method === "applyPatchApproval" || method === "execCommandApproval") {
+ return { decision: { denied: { rejection: reason } } };
+ }
+ return { decision: "decline" };
+}
+
+function normalizeServerResponseForApp(method, response) {
+ response = normalizeApprovalResponseForApp(method, response);
+ if (method === "item/permissions/requestApproval") {
+ const source = isObjectPayload(response) ? response : {};
+ const requested = isObjectPayload(source.permissions) ? source.permissions : {};
+ const permissions = {};
+ for (const [key, value] of Object.entries(requested)) {
+ if (value !== null && value !== undefined && isObjectPayload(value)) permissions[key] = value;
+ }
+ const normalized = { permissions, scope: source.scope === "session" ? "session" : "turn" };
+ if (typeof source.strictAutoReview === "boolean") normalized.strictAutoReview = source.strictAutoReview;
+ return normalized;
+ }
+ if (method === "item/tool/requestUserInput") {
+ if (isObjectPayload(response) && Object.prototype.hasOwnProperty.call(response, "answers")) return response;
+ return { answers: isObjectPayload(response) ? response : {} };
+ }
+ return response;
+}
+
+const LEGACY_APPROVAL_METHODS = new Set(["applyPatchApproval", "execCommandApproval"]);
+
+function normalizeApprovalResponseForApp(method, response) {
+ const v2Approval = new Set([
+ "item/commandExecution/requestApproval",
+ "item/fileChange/requestApproval",
+ ]);
+ if (!LEGACY_APPROVAL_METHODS.has(method) && !v2Approval.has(method)) return response;
+ if (!isObjectPayload(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) return response;
+ const decision = response.decision;
+ const legacy = LEGACY_APPROVAL_METHODS.has(method);
+ let normalized = decision;
+ if (typeof decision === "string") {
+ if (legacy) {
+ if (["allow", "accept", "approved"].includes(decision)) normalized = "approved";
+ else if (["acceptForSession", "approved_for_session"].includes(decision)) normalized = "approved_for_session";
+ else if (["deny", "decline", "denied"].includes(decision)) {
+ normalized = { denied: { rejection: "Denied remotely" } };
+ } else if (["cancel", "abort"].includes(decision)) normalized = "abort";
+ else if (decision === "timed_out") normalized = "timed_out";
+ } else {
+ if (["allow", "accept", "approved"].includes(decision)) normalized = "accept";
+ else if (["acceptForSession", "approved_for_session"].includes(decision)) normalized = "acceptForSession";
+ else if (["deny", "decline", "denied", "timed_out"].includes(decision)) normalized = "decline";
+ else if (["cancel", "abort"].includes(decision)) normalized = "cancel";
+ else if (decision === "approved_mcp_policy_amendment") normalized = "accept";
+ }
+ } else if (knownDecisionObjectKey(decision)) {
+ const decisionKey = knownDecisionObjectKey(decision);
+ if (legacy && decisionKey === "acceptWithExecpolicyAmendment") {
+ const value = decision.acceptWithExecpolicyAmendment;
+ normalized = { approved_execpolicy_amendment: { proposed_execpolicy_amendment: value?.execpolicy_amendment ?? value } };
+ } else if (legacy && decisionKey === "applyNetworkPolicyAmendment") {
+ const value = decision.applyNetworkPolicyAmendment;
+ normalized = { network_policy_amendment: { network_policy_amendment: value?.network_policy_amendment ?? value } };
+ } else if (!legacy && decisionKey === "approved_execpolicy_amendment") {
+ const value = decision.approved_execpolicy_amendment;
+ normalized = { acceptWithExecpolicyAmendment: { execpolicy_amendment: value?.proposed_execpolicy_amendment ?? value } };
+ } else if (!legacy && decisionKey === "network_policy_amendment") {
+ const value = decision.network_policy_amendment;
+ normalized = { applyNetworkPolicyAmendment: { network_policy_amendment: value?.network_policy_amendment ?? value } };
+ } else if (!legacy && decisionKey === "denied") {
+ normalized = "decline";
+ }
+ }
+ return normalized === decision ? response : { ...response, decision: normalized };
+}
+
+function isValidApprovalResponse(method, response) {
+ const legacy = LEGACY_APPROVAL_METHODS.has(method);
+ const v2 = method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval";
+ if (!legacy && !v2) return true;
+ if (!isObjectPayload(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) return false;
+ const decision = response.decision;
+ if (typeof decision === "string") {
+ return legacy
+ ? ["approved", "approved_for_session", "approved_mcp_policy_amendment", "timed_out", "abort"].includes(decision)
+ : ["accept", "acceptForSession", "decline", "cancel"].includes(decision);
+ }
+ if (!decision || typeof decision !== "object" || Array.isArray(decision)) return false;
+ const key = knownDecisionObjectKey(decision);
+ return legacy
+ ? key === "approved_execpolicy_amendment" || key === "network_policy_amendment" || key === "denied"
+ : key === "acceptWithExecpolicyAmendment" || key === "applyNetworkPolicyAmendment";
+}
+
+function knownDecisionObjectKey(value) {
+ if (!isObjectPayload(value)) return undefined;
+ const keys = Object.keys(value);
+ if (keys.length !== 1) return undefined;
+ const key = keys[0];
+ const nested = value[key];
+ if (key === "acceptWithExecpolicyAmendment") {
+ return isObjectPayload(nested)
+ && Object.keys(nested).every((field) => field === "execpolicy_amendment")
+ && isStringArray(nested.execpolicy_amendment) ? key : undefined;
+ }
+ if (key === "approved_execpolicy_amendment") {
+ return isObjectPayload(nested)
+ && Object.keys(nested).every((field) => field === "proposed_execpolicy_amendment")
+ && isStringArray(nested.proposed_execpolicy_amendment) ? key : undefined;
+ }
+ if (key === "applyNetworkPolicyAmendment") {
+ return isObjectPayload(nested)
+ && Object.keys(nested).every((field) => field === "network_policy_amendment")
+ && isNetworkPolicyAmendment(nested.network_policy_amendment) ? key : undefined;
+ }
+ if (key === "network_policy_amendment") {
+ return isObjectPayload(nested)
+ && Object.keys(nested).every((field) => field === "network_policy_amendment")
+ && isNetworkPolicyAmendment(nested.network_policy_amendment) ? key : undefined;
+ }
+ if (key === "denied") {
+ return isObjectPayload(nested)
+ && Object.keys(nested).every((field) => field === "rejection")
+ && typeof nested.rejection === "string" ? key : undefined;
+ }
+ return undefined;
+}
+
+function isStringArray(value) {
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
+}
+
+function isNetworkPolicyAmendment(value) {
+ return isObjectPayload(value)
+ && Object.keys(value).every((field) => field === "host" || field === "action")
+ && typeof value.host === "string"
+ && (value.action === "allow" || value.action === "deny");
+}
+
+class CodexRelay {
+ constructor(options = {}) {
+ this.host = options.host || process.env.HOST || "127.0.0.1";
+ this.port = parsePort(options.port ?? process.env.PORT, 8787);
+ this.eventLimit = positiveByteLimit(
+ options.eventLimit ?? process.env.CODEX_REMOTE_EVENT_LIMIT,
+ DEFAULT_EVENT_LIMIT,
+ );
+ this.eventByteLimit = positiveByteLimit(
+ options.eventByteLimit ?? process.env.CODEX_REMOTE_EVENT_BYTE_LIMIT,
+ DEFAULT_EVENT_BYTE_LIMIT,
+ );
+ this.replayByteLimit = positiveByteLimit(
+ options.replayByteLimit ?? process.env.CODEX_REMOTE_REPLAY_BYTE_LIMIT,
+ DEFAULT_REPLAY_BYTE_LIMIT,
+ );
+ this.clientBufferedByteLimit = positiveByteLimit(
+ options.clientBufferedByteLimit ?? process.env.CODEX_REMOTE_CLIENT_BUFFERED_BYTE_LIMIT,
+ DEFAULT_CLIENT_BUFFERED_BYTE_LIMIT,
+ );
+ const tokenConfigured = hasConfiguredToken(options.operatorToken)
+ || hasConfiguredToken(options.viewerToken)
+ || hasConfiguredToken(options.hostToken)
+ || hasConfiguredToken(process.env.CODEX_REMOTE_TOKEN)
+ || hasConfiguredToken(process.env.CODEX_REMOTE_VIEW_TOKEN)
+ || hasConfiguredToken(process.env.CODEX_REMOTE_HOST_TOKEN);
+ const explicitAuthRequired = Object.prototype.hasOwnProperty.call(options, "authRequired")
+ ? parseAuthRequired(options.authRequired)
+ : parseAuthRequired(process.env.CODEX_REMOTE_AUTH)
+ ?? parseAuthRequired(process.env.CODEX_REMOTE_AUTH_REQUIRED);
+ // A loopback relay is a local development tool by default. The moment a
+ // token is configured, or the relay binds a non-loopback address, retain
+ // the authenticated behavior. `authRequired` can explicitly enable auth
+ // for a local relay; disabling it is intentionally limited to loopback.
+ this.authRequired = explicitAuthRequired ?? (tokenConfigured || !isLoopbackHost(this.host));
+ if (!this.authRequired && !isLoopbackHost(this.host)) this.authRequired = true;
+ this.operatorToken = options.operatorToken || process.env.CODEX_REMOTE_TOKEN || randomToken();
+ this.viewerToken = options.viewerToken || process.env.CODEX_REMOTE_VIEW_TOKEN || randomToken();
+ this.hostToken = options.hostToken || process.env.CODEX_REMOTE_HOST_TOKEN || this.operatorToken;
+ const configuredApprovalTimeout = Number(options.approvalTimeoutMs ?? process.env.CODEX_REMOTE_APPROVAL_TIMEOUT_MS);
+ this.approvalTimeoutMs = Number.isFinite(configuredApprovalTimeout) && configuredApprovalTimeout >= 0
+ ? configuredApprovalTimeout
+ : DEFAULT_APPROVAL_TIMEOUT_MS;
+ this.generatedOperatorToken = !options.operatorToken && !process.env.CODEX_REMOTE_TOKEN;
+ this.generatedViewerToken = !options.viewerToken && !process.env.CODEX_REMOTE_VIEW_TOKEN;
+ this.codexCommand = options.codexCommand || process.env.CODEX_BIN || "codex";
+ this.codexArgs = options.codexArgs || this.readCodexArgs();
+ this.codexCwd = options.codexCwd || process.env.CODEX_CWD || process.cwd();
+ const spawnConfigured = options.spawnCodex === true
+ || process.env.CODEX_SPAWN === "true"
+ || process.env.CODEX_SPAWN === "1";
+ // Attaching to the already-open VS Code Codex session is the safe default.
+ // Keep the standalone app-server path available only when it is explicit.
+ this.mode = options.mode
+ || process.env.CODEX_REMOTE_MODE
+ || (options.spawnCodex === false || process.env.CODEX_SPAWN === "false"
+ ? "host"
+ : spawnConfigured ? "embedded" : "host");
+ this.spawnCodex = this.mode === "host"
+ ? false
+ : options.spawnCodex !== undefined
+ ? options.spawnCodex !== false
+ : process.env.CODEX_SPAWN !== "false";
+ this.events = [];
+ this.eventSizes = [];
+ this.eventBytes = 0;
+ this.audit = [];
+ this.clients = new Set();
+ // At most one outbound VS Code host is active for this MVP session. A
+ // host is optional: when absent, the relay can run its embedded stdio
+ // app-server. When present, browser commands are proxied to the host.
+ this.hostClient = null;
+ this.pendingHostCommands = new Map();
+ this.pendingAppRequests = new Map();
+ this.pendingServerRequests = new Map();
+ this.commandResults = new Map();
+ // Command idempotency is scoped to the embedded app or to one stable host
+ // session. Keep the scope metadata separate so it never crosses the wire.
+ this.commandResultScopes = new Map();
+ this.hostCommandScope = null;
+ this.nextSeq = 0;
+ this.appRequestCounter = 0;
+ this.appBuffer = "";
+ this.appProcess = null;
+ this.appGeneration = 0;
+ this.appTerminalGeneration = 0;
+ this.initializedResult = null;
+ this.state = {
+ app: this.spawnCodex ? "starting" : "waiting_for_host",
+ initialized: false,
+ activeThreadId: null,
+ activeTurnId: null,
+ cwd: this.codexCwd,
+ outputTail: "",
+ messages: [],
+ subagents: [],
+ // Keep non-sensitive session metadata available for browsers that join
+ // after the adapter's original session.snapshot event was replayed.
+ sessionMetadata: null,
+ // Typed turn/activity projection from the attached VS Code host. Keep
+ // this in the relay snapshot so a browser that connects after the last
+ // event still knows whether the conversation is thinking, editing, or
+ // waiting for approval.
+ executionStatus: null,
+ lastError: null,
+ mode: this.mode,
+ authRequired: this.authRequired,
+ hostConnected: false,
+ hostSessionId: null,
+ };
+ }
+
+ readCodexArgs() {
+ if (!process.env.CODEX_ARGS_JSON) return ["app-server", "--stdio"];
+ try {
+ const args = JSON.parse(process.env.CODEX_ARGS_JSON);
+ if (!Array.isArray(args) || !args.every((entry) => typeof entry === "string")) throw new Error();
+ return args;
+ } catch {
+ throw new Error("CODEX_ARGS_JSON must be a JSON array of strings");
+ }
+ }
+
+ async start() {
+ this.httpServer = http.createServer((request, response) => this.handleHttp(request, response));
+ this.wsServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
+ this.wsServer.on("connection", (socket, request) => this.handleConnection(socket, request));
+ this.httpServer.on("upgrade", (request, socket, head) => this.handleUpgrade(request, socket, head));
+
+ await new Promise((resolve, reject) => {
+ const onError = (error) => reject(error);
+ this.httpServer.once("error", onError);
+ this.httpServer.listen(this.port, this.host, () => {
+ this.httpServer.off("error", onError);
+ resolve();
+ });
+ });
+
+ if (this.spawnCodex) this.startCodex();
+ return this.address();
+ }
+
+ address() {
+ const address = this.httpServer.address();
+ if (!address || typeof address === "string") return { host: this.host, port: this.port };
+ return { host: address.address, port: address.port };
+ }
+
+ async stop() {
+ for (const client of this.clients) client.socket.close(1001, "relay shutting down");
+ this.clients.clear();
+ this.hostClient = null;
+ this.pendingHostCommands.clear();
+ this.pendingAppRequests.clear();
+ this.commandResults.clear();
+ this.commandResultScopes.clear();
+ this.hostCommandScope = null;
+ for (const pending of this.pendingServerRequests.values()) {
+ if (pending.timer) clearTimeout(pending.timer);
+ }
+ this.pendingServerRequests.clear();
+ if (this.appProcess && !this.appProcess.killed) this.appProcess.kill("SIGTERM");
+ if (this.wsServer) await new Promise((resolve) => this.wsServer.close(() => resolve()));
+ if (this.httpServer) await new Promise((resolve) => this.httpServer.close(() => resolve()));
+ }
+
+ startCodex() {
+ if (this.appProcess && this.appProcess.exitCode === null && !this.appProcess.killed) return;
+ this.state.app = "starting";
+ const child = spawn(this.codexCommand, this.codexArgs, {
+ cwd: this.codexCwd,
+ env: process.env,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ const generation = ++this.appGeneration;
+ this.appProcess = child;
+
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ if (this.appProcess !== child || this.appGeneration !== generation) return;
+ this.consumeAppOutput(chunk);
+ });
+ child.stderr.on("data", (chunk) => {
+ if (this.appProcess !== child || this.appGeneration !== generation) return;
+ const text = redactString(chunk).slice(0, 32_000);
+ this.recordEvent("app.stderr", { stream: "stderr", text });
+ });
+ child.stdin.on("error", (error) => {
+ if (this.appProcess !== child || this.appGeneration !== generation) return;
+ this.handleAppProcessExit(child, generation, { error });
+ });
+ child.on("error", (error) => {
+ this.handleAppProcessExit(child, generation, { error });
+ });
+ child.on("exit", (code, signal) => {
+ this.handleAppProcessExit(child, generation, { code, signal });
+ });
+
+ this.state.app = "initializing";
+ const initId = this.nextAppRequestId("initialize");
+ this.pendingAppRequests.set(jsonRpcIdKey(initId), { kind: "initialize", method: "initialize" });
+ try {
+ this.sendToApp({
+ method: "initialize",
+ id: initId,
+ params: {
+ clientInfo: {
+ name: "codex-remote-collab",
+ title: "Codex Remote Collab",
+ version: "0.1.0",
+ },
+ capabilities: {
+ experimentalApi: true,
+ requestAttestation: false,
+ },
+ },
+ });
+ } catch (error) {
+ this.handleAppProcessExit(child, generation, { error });
+ }
+ }
+
+ handleAppProcessExit(child, generation, details = {}) {
+ if (this.appProcess !== child || this.appGeneration !== generation) return;
+ // ChildProcess can emit both `error` and `exit`; process one terminal
+ // transition so pending commands/requests are settled exactly once.
+ if (this.appTerminalGeneration === generation) return;
+ this.appTerminalGeneration = generation;
+ this.appProcess = null;
+ this.appBuffer = "";
+
+ const previousThreadId = this.state.activeThreadId;
+ const error = details.error;
+ const code = details.code;
+ const signal = details.signal;
+ const reason = error?.message
+ || `Codex app-server exited (code=${String(code)}, signal=${String(signal)})`;
+ const terminalError = {
+ code: error?.code ? String(error.code) : "app_exited",
+ message: redactString(reason),
+ retryable: true,
+ };
+ this.state.app = "offline";
+ this.state.initialized = false;
+ this.state.activeThreadId = null;
+ this.state.activeTurnId = null;
+ this.state.lastError = terminalError;
+ // Results from a dead app-server cannot safely be reused after a restart:
+ // a command may have applied side effects before the process crashed.
+ this.clearCommandResults();
+ this.recordEvent("app.exited", error
+ ? { error: terminalError }
+ : { code, signal, error: terminalError });
+
+ const pendingCommands = [...this.pendingAppRequests.values()];
+ this.pendingAppRequests.clear();
+ for (const pending of pendingCommands) {
+ if (!pending.commandId) continue;
+ const payload = {
+ commandId: pending.commandId,
+ method: pending.method || null,
+ ok: false,
+ uncertain: true,
+ retryable: true,
+ error: terminalError,
+ };
+ this.cacheCommandResult(pending.commandId, payload, "embedded");
+ const event = this.recordEvent("command.result", payload);
+ if (pending.client) {
+ this.sendControl(pending.client, { type: "command.result", ...payload, seq: event.seq });
+ }
+ }
+
+ const pendingRequests = [...this.pendingServerRequests.values()];
+ for (const pending of pendingRequests) {
+ this.clearPendingServerRequest(pending.appId);
+ const isInput = pending.method === "item/tool/requestUserInput"
+ || pending.method === "mcpServer/elicitation/request";
+ this.recordEvent(isInput ? "input.expired" : "approval.expired", {
+ requestId: pending.appId,
+ method: pending.method,
+ reason: "Codex app-server exited",
+ error: terminalError,
+ }, { sessionId: previousThreadId || undefined });
+ }
+ }
+
+ nextAppRequestId(label) {
+ this.appRequestCounter += 1;
+ return `relay-${label}-${this.appRequestCounter}`;
+ }
+
+ consumeAppOutput(chunk) {
+ this.appBuffer += chunk;
+ while (true) {
+ const newline = this.appBuffer.indexOf("\n");
+ if (newline < 0) break;
+ const line = this.appBuffer.slice(0, newline).trim();
+ this.appBuffer = this.appBuffer.slice(newline + 1);
+ if (!line) continue;
+ try {
+ this.handleAppMessage(JSON.parse(line));
+ } catch (error) {
+ this.recordEvent("app.parse_error", {
+ error: normalizeError(error),
+ line: redactString(line).slice(0, 2_000),
+ });
+ }
+ }
+ }
+
+ handleAppMessage(rawMessage) {
+ const message = redact(rawMessage);
+ if (message.id !== undefined && message.method) {
+ const requestId = rawMessage.id;
+ this.clearPendingServerRequest(requestId);
+ const pending = {
+ appId: requestId,
+ method: message.method,
+ params: message.params || {},
+ createdAt: Date.now(),
+ };
+ this.scheduleServerRequestExpiry(requestId, pending);
+ this.pendingServerRequests.set(jsonRpcIdKey(requestId), pending);
+ this.recordEvent(eventTypeForAppMessage(message), {
+ requestId,
+ method: message.method,
+ params: message.params || {},
+ });
+ return;
+ }
+
+ if (message.id !== undefined) {
+ const key = jsonRpcIdKey(rawMessage.id);
+ const pending = this.pendingAppRequests.get(key);
+ this.pendingAppRequests.delete(key);
+
+ if (pending && pending.kind === "initialize") {
+ if (message.result) {
+ this.initializedResult = message.result;
+ this.state.app = "ready";
+ this.state.initialized = true;
+ // The app-server handshake is ordered: initialized follows the
+ // successful initialize response. Some versions reject an early
+ // notification or process it before capabilities are established.
+ try {
+ this.sendToApp({ method: "initialized", params: {} });
+ } catch (error) {
+ this.state.app = "error";
+ this.state.initialized = false;
+ this.state.lastError = normalizeError(error);
+ }
+ this.recordEvent("app.ready", { result: message.result });
+ } else {
+ this.state.app = "error";
+ this.state.lastError = message.error || { message: "Codex initialization failed" };
+ this.recordEvent("app.error", { error: this.state.lastError });
+ }
+ return;
+ }
+
+ if (pending && pending.method === "thread/start" && message.result?.thread?.id) {
+ this.state.activeThreadId = message.result.thread.id;
+ this.state.cwd = message.result.cwd || this.state.cwd;
+ }
+ if (pending && pending.method === "turn/start" && message.result?.turn?.id) {
+ this.state.activeTurnId = message.result.turn.id;
+ }
+
+ const commandPayload = {
+ commandId: pending?.commandId || null,
+ method: pending?.method || null,
+ ok: !message.error,
+ result: message.result,
+ error: message.error,
+ };
+ if (pending?.commandId) {
+ this.cacheCommandResult(pending.commandId, commandPayload, "embedded");
+ }
+ const event = this.recordEvent("command.result", commandPayload);
+ if (pending?.client) this.sendControl(pending.client, { ...commandPayload, type: "command.result", seq: event.seq });
+ return;
+ }
+
+ this.updateStateFromNotification(message);
+ this.recordEvent(eventTypeForAppMessage(message), {
+ method: message.method || "unknown",
+ params: message.params || {},
+ text: outputText(message.method || "", message.params),
+ emittedAtMs: message.emittedAtMs,
+ });
+ }
+
+ updateStateFromNotification(message) {
+ const params = message.params || {};
+ if (message.method === "thread/started" && params.thread?.id) this.state.activeThreadId = params.thread.id;
+ if (message.method === "turn/started" && params.turn?.id) this.state.activeTurnId = params.turn.id;
+ if (message.method === "turn/completed" && (!params.turn?.id || params.turn.id === this.state.activeTurnId)) {
+ this.state.activeTurnId = null;
+ }
+ }
+
+ sendToApp(message) {
+ if (!this.appProcess
+ || this.appProcess.exitCode !== null
+ || this.appProcess.killed
+ || !this.appProcess.stdin
+ || this.appProcess.stdin.destroyed
+ || !this.appProcess.stdin.writable) {
+ throw Object.assign(new Error("Codex app-server is offline"), {
+ code: "app_offline",
+ retryable: true,
+ });
+ }
+ this.appProcess.stdin.write(`${JSON.stringify(message)}\n`);
+ }
+
+ recordEvent(type, payload, options = {}) {
+ const event = {
+ v: 1,
+ kind: "event",
+ id: randomId("evt"),
+ seq: ++this.nextSeq,
+ ts: new Date().toISOString(),
+ type,
+ sessionId: options.sessionId || this.state.activeThreadId,
+ payload: redact(payload),
+ };
+ const replayEvent = compactTranscriptEventForReplay(event);
+ const replayBytes = jsonByteLength(replayEvent);
+ // An individual event that exceeds the entire replay budget is still
+ // delivered live and represented by the authoritative state snapshot. Do
+ // not let it make the in-memory ring exceed its configured hard bound.
+ if (replayBytes <= this.eventByteLimit) {
+ this.events.push(replayEvent);
+ this.eventSizes.push(replayBytes);
+ this.eventBytes += replayBytes;
+ }
+ while (this.events.length > this.eventLimit || this.eventBytes > this.eventByteLimit) {
+ this.events.shift();
+ this.eventBytes -= this.eventSizes.shift() || 0;
+ }
+ for (const client of this.clients) {
+ if (client.authenticated && client.subscribed && client !== options.excludeClient) this.sendControl(client, event);
+ }
+ return event;
+ }
+
+ auditAction(client, action, details, outcome) {
+ this.audit.push({
+ id: randomId("audit"),
+ ts: new Date().toISOString(),
+ actor: client?.id || "http",
+ role: client?.role || "unknown",
+ action,
+ details: redact(details),
+ outcome,
+ });
+ if (this.audit.length > 500) this.audit.splice(0, this.audit.length - 500);
+ }
+
+ handleUpgrade(request, socket, head) {
+ let requestUrl;
+ try {
+ requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`);
+ } catch {
+ socket.destroy();
+ return;
+ }
+ if (requestUrl.pathname !== "/ws" && requestUrl.pathname !== "/v1/connect") {
+ socket.destroy();
+ return;
+ }
+ if (!this.authRequired && !isLoopbackRequestHost(request)) {
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+ const origin = request.headers.origin;
+ if (origin) {
+ try {
+ if (new URL(origin).host.toLowerCase() !== String(request.headers.host || "").toLowerCase()) {
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+ } catch {
+ socket.destroy();
+ return;
+ }
+ }
+ this.wsServer.handleUpgrade(request, socket, head, (webSocket) => {
+ this.wsServer.emit("connection", webSocket, request);
+ });
+ }
+
+ handleConnection(socket, request) {
+ const client = {
+ id: randomId("client"),
+ socket,
+ role: null,
+ authenticated: false,
+ subscribed: false,
+ clientType: null,
+ sessionId: null,
+ commandScope: null,
+ lastSeq: 0,
+ remoteAddress: request.socket.remoteAddress,
+ };
+ this.clients.add(client);
+ const authTimer = setTimeout(() => {
+ if (!client.authenticated) socket.close(1008, "authentication required");
+ }, 10_000);
+ authTimer.unref();
+
+ socket.on("message", (data, isBinary) => {
+ if (isBinary) {
+ socket.close(1003, "JSON text frames only");
+ return;
+ }
+ let message;
+ try {
+ message = JSON.parse(data.toString("utf8"));
+ } catch {
+ this.sendControl(client, { type: "error", code: "invalid_json", message: "Invalid JSON frame" });
+ return;
+ }
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
+ this.sendControl(client, { type: "error", code: "invalid_frame", message: "JSON frame must be an object" });
+ return;
+ }
+ try {
+ this.handleClientMessage(client, message);
+ } catch (error) {
+ this.recordEvent("relay.error", { clientId: client.id, error: normalizeError(error) });
+ this.sendControl(client, { type: "error", ...normalizeError(error) });
+ }
+ });
+ socket.on("close", () => {
+ clearTimeout(authTimer);
+ this.clients.delete(client);
+ if (this.hostClient === client) {
+ this.hostClient = null;
+ this.state.hostConnected = false;
+ this.state.hostSessionId = null;
+ this.state.app = "offline";
+ this.state.initialized = false;
+ this.state.activeThreadId = null;
+ this.state.activeTurnId = null;
+ this.state.lastError = { code: "host_disconnected", message: "VS Code host disconnected" };
+ this.recordEvent("host.disconnected", { clientId: client.id }, { excludeClient: client });
+ // Commands waiting on a host cannot be completed after its socket is
+ // gone. Keep their ids reserved briefly so retries get a clear error.
+ for (const [commandId, pending] of this.pendingHostCommands) {
+ this.pendingHostCommands.delete(commandId);
+ this.cacheCommandResult(commandId, {
+ commandId,
+ method: pending.method,
+ ok: false,
+ uncertain: true,
+ error: { code: "host_disconnected", message: "VS Code host disconnected" },
+ }, pending.commandScope || client.commandScope || this.hostCommandScope);
+ if (pending.client) {
+ if (pending.kind === "server-response") {
+ this.sendControl(pending.client, {
+ type: "response.rejected",
+ requestId: pending.responseRequestId ?? pending.requestId,
+ code: "host_disconnected",
+ message: "VS Code host disconnected",
+ retryable: true,
+ });
+ } else {
+ this.sendControl(pending.client, {
+ type: "command.result",
+ commandId,
+ method: pending.method,
+ ok: false,
+ uncertain: true,
+ error: { code: "host_disconnected", message: "VS Code host disconnected" },
+ });
+ }
+ }
+ }
+ for (const pending of this.pendingServerRequests.values()) {
+ if (pending.source !== "host" || pending.hostClient !== client) continue;
+ this.clearPendingServerRequest(pending.appId);
+ this.recordEvent(
+ pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request"
+ ? "input.expired"
+ : "approval.expired",
+ { requestId: pending.appId, method: pending.method, reason: "VS Code host disconnected" },
+ { sessionId: client.sessionId || undefined },
+ );
+ }
+ }
+ if (client.authenticated) this.recordEvent("presence.changed", { clientId: client.id, state: "offline" });
+ });
+ socket.on("error", () => {});
+ }
+
+ roleForToken(token) {
+ if (secureEqual(token, this.operatorToken)) return "operator";
+ if (secureEqual(token, this.viewerToken)) return "viewer";
+ return null;
+ }
+
+ handleClientMessage(client, message) {
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
+ this.sendControl(client, { type: "error", code: "invalid_frame", message: "JSON frame must be an object" });
+ return;
+ }
+ if (!client.authenticated) {
+ // The VS Code bridge sends a hello frame before its auth frame. Keep the
+ // hello unauthenticated but remember the client kind and resume cursor.
+ const localConnection = !this.authRequired && isLoopbackAddress(client.remoteAddress);
+ const localNoAuthHandshake = localConnection
+ && (message.kind === "hello" || message.kind === "auth" || message.type === "auth");
+ let token = null;
+ if (message.kind === "hello") {
+ if (message.protocol !== undefined && Number(message.protocol) !== 1) {
+ this.sendControl(client, { type: "error", code: "unsupported_protocol", message: "Only protocol 1 is supported" });
+ client.socket.close(1002, "unsupported protocol");
+ return;
+ }
+ client.clientType = message.clientType === "host" ? "host" : "web";
+ client.sessionId = typeof message.sessionId === "string" ? message.sessionId : null;
+ client.lastSeq = Number.isFinite(Number(message.lastSeq)) ? Number(message.lastSeq) : 0;
+ token = typeof message.token === "string"
+ ? message.token
+ : typeof message.accessToken === "string"
+ ? message.accessToken
+ : null;
+ if (!token && !localConnection) return;
+ } else {
+ token = message.type === "auth" && typeof message.token === "string"
+ ? message.token
+ : message.kind === "auth" && typeof message.accessToken === "string"
+ ? message.accessToken
+ : message.kind === "auth" && typeof message.token === "string"
+ ? message.token
+ : null;
+ }
+ if (!token && !localNoAuthHandshake) {
+ client.socket.close(1008, "authentication required");
+ return;
+ }
+ const isHost = client.clientType === "host";
+ const role = localNoAuthHandshake
+ ? (isHost ? "host" : "operator")
+ : (isHost && secureEqual(token, this.hostToken) ? "operator" : this.roleForToken(token));
+ if (!role || (!localNoAuthHandshake && isHost && !secureEqual(token, this.hostToken))) {
+ this.auditAction(client, "authenticate", {}, "denied");
+ client.socket.close(1008, "invalid token");
+ return;
+ }
+ client.authenticated = true;
+ client.clientType = client.clientType || "web";
+ client.role = isHost ? "host" : role;
+ if (client.clientType === "host") {
+ if (this.mode === "embedded") {
+ this.sendControl(client, { type: "error", code: "host_mode_disabled", message: "Start relay with CODEX_REMOTE_MODE=host (or CODEX_SPAWN=false) for a VS Code host" });
+ client.socket.close(1008, "host mode disabled");
+ return;
+ }
+ if (this.hostClient && this.hostClient !== client) {
+ this.sendControl(client, { type: "error", code: "host_already_connected", message: "A VS Code host is already connected" });
+ client.socket.close(1008, "host already connected");
+ return;
+ }
+ const hostSessionId = typeof client.sessionId === "string" && client.sessionId.length > 0
+ ? client.sessionId
+ : null;
+ const commandScope = hostSessionId ? `session:${hostSessionId}` : `connection:${client.id}`;
+ // A command id is only idempotent within the same host session. A
+ // reconnect with the same stable session id may reuse the cache;
+ // another session must never inherit old results (including uncertain
+ // disconnect results).
+ if (this.hostCommandScope !== null && this.hostCommandScope !== commandScope) {
+ this.clearCommandResults();
+ }
+ client.commandScope = commandScope;
+ this.hostCommandScope = commandScope;
+ if (this.state.hostSessionId && this.state.hostSessionId !== client.sessionId) {
+ this.state.activeThreadId = null;
+ this.state.activeTurnId = null;
+ }
+ this.hostClient = client;
+ this.state.hostConnected = true;
+ this.state.hostSessionId = client.sessionId;
+ this.recordEvent("host.connected", { clientId: client.id, sessionId: client.sessionId }, { excludeClient: client });
+ }
+ this.auditAction(client, "authenticate", {}, "accepted");
+ this.sendControl(client, {
+ type: "auth.ok",
+ clientId: client.id,
+ role: client.role,
+ clientType: client.clientType,
+ protocol: 1,
+ authRequired: this.authRequired,
+ latestSeq: this.nextSeq,
+ });
+ return;
+ }
+
+ // Host frames use the versioned relay contract; browser frames use the
+ // compact `type` contract. Host events are ingested and re-sequenced here
+ // instead of echoed back to the host.
+ if (message.kind === "hello") {
+ const announcedType = message.clientType === "host" ? "host" : "web";
+ if (announcedType !== client.clientType) {
+ this.sendControl(client, { type: "error", code: "client_type_immutable", message: "clientType cannot change after authentication" });
+ return;
+ }
+ client.sessionId = typeof message.sessionId === "string" ? message.sessionId : client.sessionId;
+ return;
+ }
+ if (message.kind === "auth" || message.type === "auth") {
+ return;
+ }
+ if (message.kind === "ack") return;
+ if (message.kind === "event" && client.clientType === "host") {
+ this.ingestHostEvent(client, message);
+ return;
+ }
+
+ if (message.type === "subscribe") {
+ this.subscribe(client, Number(message.fromSeq || 0));
+ return;
+ }
+ if (message.type === "ping") {
+ this.sendControl(client, { type: "pong", ts: new Date().toISOString(), latestSeq: this.nextSeq });
+ return;
+ }
+ // Browser clients historically used compact `{type:"command", method,
+ // params}` / `{type:"respond", requestId, result}` frames. The bridge
+ // contract is versioned and uses `{kind:"command", type, payload}` (and
+ // approval/input response command names). Normalize both forms at this
+ // boundary; host clients are event producers and must not issue relay
+ // commands back into themselves.
+ if (client.clientType !== "host") {
+ const command = normalizeBrowserCommand(message);
+ if (command) {
+ if (REMOTE_RESPONSE_METHODS.has(command.method)) {
+ this.dispatchServerResponse(client, normalizeBrowserResponse(message, command.method));
+ } else {
+ this.dispatchCommand(client, command);
+ }
+ return;
+ }
+ const response = normalizeBrowserResponse(message);
+ if (response) {
+ this.dispatchServerResponse(client, response);
+ return;
+ }
+ }
+ this.sendControl(client, { type: "error", code: "unknown_frame", message: "Unknown frame type" });
+ }
+
+ subscribe(client, fromSeq) {
+ const firstAvailable = this.events.length ? this.events[0].seq : this.nextSeq + 1;
+ const replayEvents = [];
+ let replayBytes = 0;
+ let replayTooLarge = false;
+ if (fromSeq + 1 >= firstAvailable) {
+ for (const event of this.events) {
+ if (event.seq <= fromSeq) continue;
+ const size = jsonByteLength(event);
+ if (replayBytes + size > this.replayByteLimit) {
+ replayTooLarge = true;
+ break;
+ }
+ replayEvents.push(event);
+ replayBytes += size;
+ }
+ }
+ if (fromSeq + 1 < firstAvailable || replayTooLarge) {
+ this.sendControl(client, {
+ type: "resync.required",
+ requestedFromSeq: fromSeq,
+ firstAvailableSeq: firstAvailable,
+ ...(replayTooLarge ? { reason: "replay_too_large" } : {}),
+ });
+ } else {
+ for (const event of replayEvents) this.sendControl(client, event);
+ }
+ client.subscribed = true;
+ // Other subscribers need the presence transition, while the joining
+ // client receives the same fact in the clients list of its snapshot. Add
+ // it first so `latestSeq` covers every authoritative state transition.
+ this.recordEvent("presence.changed", { clientId: client.id, role: client.role, state: "online" }, { excludeClient: client });
+ this.sendControl(client, { type: "session.snapshot", snapshot: this.snapshot() });
+ }
+
+ ingestHostEvent(client, frame) {
+ const sourceType = typeof frame.type === "string" ? frame.type : "app.notification";
+ const sourcePayload = frame.payload && typeof frame.payload === "object" ? frame.payload : {};
+ const sourceSeq = Number.isFinite(Number(frame.seq)) ? Number(frame.seq) : undefined;
+ const sessionId = client.sessionId || frame.sessionId || this.state.hostSessionId || undefined;
+
+ const executionStatus = sourcePayload.executionStatus && typeof sourcePayload.executionStatus === "object"
+ ? sourcePayload.executionStatus
+ : frame.status && typeof frame.status === "object"
+ ? frame.status
+ : sourcePayload.status && typeof sourcePayload.status === "object"
+ ? sourcePayload.status
+ : null;
+ if (executionStatus) this.state.executionStatus = redact(executionStatus);
+
+ // Keep the browser snapshot useful even though the host deliberately uses
+ // a normalized event vocabulary instead of raw app-server notifications.
+ if (sourceType === "session.created" && sourcePayload.thread && typeof sourcePayload.thread === "object") {
+ const id = sourcePayload.thread.id;
+ if (typeof id === "string") this.state.activeThreadId = id;
+ }
+ if (sourceType === "session.snapshot") {
+ const threadId = sourcePayload.threadId || sourcePayload.thread?.id;
+ const turnId = sourcePayload.turnId || sourcePayload.turn?.id;
+ if (typeof threadId === "string") this.state.activeThreadId = threadId;
+ if (typeof turnId === "string") this.state.activeTurnId = turnId;
+ if (sourcePayload.threadId === null || sourcePayload.thread === null) this.state.activeThreadId = null;
+ if (sourcePayload.turnId === null || sourcePayload.turn === null) this.state.activeTurnId = null;
+ this.state.app = "ready";
+ this.state.initialized = true;
+ this.state.lastError = null;
+ if (typeof sourcePayload.outputTail === "string") this.state.outputTail = sourcePayload.outputTail;
+ if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages;
+ if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) {
+ const metadata = sourcePayload.metadata;
+ this.state.sessionMetadata = redactSessionMetadata({
+ ...(typeof metadata.title === "string" ? { title: metadata.title } : {}),
+ ...(typeof metadata.name === "string" ? { name: metadata.name } : {}),
+ ...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}),
+ ...(typeof metadata.mode === "string" ? { mode: metadata.mode } : {}),
+ ...(metadata.controlMode === "sync" || metadata.controlMode === "async" ? { controlMode: metadata.controlMode } : {}),
+ ...(Number.isSafeInteger(metadata.modeEpoch) && metadata.modeEpoch >= 0 ? { modeEpoch: metadata.modeEpoch } : {}),
+ ...(metadata.capabilities && typeof metadata.capabilities === "object" && !Array.isArray(metadata.capabilities) ? { capabilities: metadata.capabilities } : {}),
+ ...(typeof metadata.source === "string" ? { source: metadata.source } : {}),
+ ...(typeof metadata.historyComplete === "boolean" ? { historyComplete: metadata.historyComplete } : {}),
+ ...(typeof metadata.waitingForSession === "boolean" ? { waitingForSession: metadata.waitingForSession } : {}),
+ ...(typeof metadata.attachReady === "boolean" ? { attachReady: metadata.attachReady } : {}),
+ ...(typeof metadata.model === "string" ? { model: metadata.model } : {}),
+ ...(typeof metadata.latestModel === "string" ? { latestModel: metadata.latestModel } : {}),
+ ...(typeof metadata.effort === "string" || metadata.effort === null ? { effort: metadata.effort } : {}),
+ ...(typeof metadata.latestReasoningEffort === "string" || metadata.latestReasoningEffort === null ? { latestReasoningEffort: metadata.latestReasoningEffort } : {}),
+ ...(typeof metadata.modelName === "string" ? { modelName: metadata.modelName } : {}),
+ ...(typeof metadata.modelProvider === "string" ? { modelProvider: metadata.modelProvider } : {}),
+ ...(typeof metadata.approvalPolicy === "string" ? { approvalPolicy: metadata.approvalPolicy } : {}),
+ ...(typeof metadata.approvalsReviewer === "string" ? { approvalsReviewer: metadata.approvalsReviewer } : {}),
+ ...(typeof metadata.sandboxPolicy === "string" ? { sandboxPolicy: metadata.sandboxPolicy } : {}),
+ ...(metadata.approvalPolicy && typeof metadata.approvalPolicy === "object" ? { approvalPolicy: metadata.approvalPolicy } : {}),
+ ...(metadata.approvalsReviewer === null ? { approvalsReviewer: null } : {}),
+ ...(metadata.sandboxPolicy && typeof metadata.sandboxPolicy === "object" ? { sandboxPolicy: metadata.sandboxPolicy } : {}),
+ ...(typeof metadata.permissions === "string" || (metadata.permissions && typeof metadata.permissions === "object") || metadata.permissions === null ? { permissions: metadata.permissions } : {}),
+ ...(typeof metadata.currentPermissions === "string" || (metadata.currentPermissions && typeof metadata.currentPermissions === "object") || metadata.currentPermissions === null ? { currentPermissions: metadata.currentPermissions } : {}),
+ ...(Array.isArray(metadata.runtimeWorkspaceRoots) ? { runtimeWorkspaceRoots: metadata.runtimeWorkspaceRoots } : {}),
+ ...(typeof metadata.workedDurationMs === "number" ? { workedDurationMs: metadata.workedDurationMs } : {}),
+ ...(typeof metadata.firstTurnWorkItemStartedAtMs === "number" ? { firstTurnWorkItemStartedAtMs: metadata.firstTurnWorkItemStartedAtMs } : {}),
+ ...(typeof metadata.finalAssistantStartedAtMs === "number" ? { finalAssistantStartedAtMs: metadata.finalAssistantStartedAtMs } : {}),
+ ...(metadata.tokenUsage && typeof metadata.tokenUsage === "object" ? { tokenUsage: metadata.tokenUsage } : metadata.tokenUsage === null ? { tokenUsage: null } : {}),
+ ...(metadata.latestTokenUsageInfo && typeof metadata.latestTokenUsageInfo === "object" ? { latestTokenUsageInfo: metadata.latestTokenUsageInfo } : metadata.latestTokenUsageInfo === null ? { latestTokenUsageInfo: null } : {}),
+ ...(metadata.threadSettings && typeof metadata.threadSettings === "object" ? { threadSettings: metadata.threadSettings } : {}),
+ ...(Array.isArray(metadata.availableModels) ? { availableModels: metadata.availableModels } : {}),
+ ...(Array.isArray(metadata.models) ? { models: metadata.models } : {}),
+ ...(Array.isArray(metadata.subagents) ? { subagents: metadata.subagents } : {}),
+ ...(typeof metadata.parentThreadId === "string" ? { parentThreadId: metadata.parentThreadId } : {}),
+ ...(typeof metadata.agentNickname === "string" ? { agentNickname: metadata.agentNickname } : {}),
+ ...(typeof metadata.agentRole === "string" ? { agentRole: metadata.agentRole } : {}),
+ });
+ }
+ if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents);
+ else if (Array.isArray(sourcePayload.metadata?.subagents)) this.state.subagents = redact(sourcePayload.metadata.subagents);
+ for (const request of Array.isArray(sourcePayload.pendingRequests) ? sourcePayload.pendingRequests : []) {
+ if (!request || typeof request !== "object" || request.requestId === undefined) continue;
+ const requestId = request.requestId;
+ this.clearPendingServerRequest(requestId);
+ const pending = {
+ appId: requestId,
+ method: typeof request.method === "string" ? request.method : "server.request",
+ params: request.params && typeof request.params === "object" ? request.params : {},
+ ...(typeof request.risk === "string" ? { risk: request.risk } : {}),
+ ...(typeof request.summary === "string" ? { summary: request.summary } : {}),
+ createdAt: Number.isFinite(Number(request.createdAt)) ? Number(request.createdAt) : Date.now(),
+ ...(Number.isFinite(Number(request.expiresAt)) ? { expiresAt: Number(request.expiresAt) } : {}),
+ source: "host",
+ hostClient: client,
+ commandHash: typeof request.commandHash === "string" ? request.commandHash : undefined,
+ };
+ if (pending.expiresAt && pending.expiresAt <= Date.now()) continue;
+ this.pendingServerRequests.set(jsonRpcIdKey(requestId), pending);
+ }
+ }
+ if (sourceType === "session.switching") {
+ const targetThreadId = sourcePayload.targetThreadId || sourcePayload.threadId;
+ if (typeof targetThreadId === "string") this.state.activeThreadId = targetThreadId;
+ // The old transcript belongs to the previous thread. Clear it before
+ // the target's authoritative snapshot arrives so a remote picker never
+ // briefly renders messages from two sessions together.
+ this.state.activeTurnId = null;
+ this.state.outputTail = "";
+ this.state.messages = [];
+ this.state.subagents = [];
+ this.state.sessionMetadata = null;
+ this.state.executionStatus = null;
+ }
+ if (sourceType === "session.selected") {
+ const selectedThreadId = sourcePayload.threadId || sourcePayload.activeThreadId;
+ if (typeof selectedThreadId === "string") this.state.activeThreadId = selectedThreadId;
+ }
+ if (sourceType === "output.snapshot") {
+ if (typeof sourcePayload.text === "string") this.state.outputTail = sourcePayload.text;
+ if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages;
+ if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents);
+ if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) {
+ // Output snapshots from older hosts occasionally carry the metadata
+ // projection instead of a separate session.snapshot event. Preserve
+ // the safe projection so model, permission, and usage controls remain
+ // available after reconnect.
+ this.state.sessionMetadata = redactSessionMetadata(sourcePayload.metadata);
+ }
+ } else if (sourceType === "output.chunk") {
+ // New attach adapters carry the complete role-aware projection alongside
+ // the append-only delta. Preserve both so reconnects do not flatten
+ // reasoning, tools, edits, or Markdown into one assistant transcript.
+ if (typeof sourcePayload.outputTail === "string") this.state.outputTail = sourcePayload.outputTail;
+ else if (typeof sourcePayload.text === "string") this.state.outputTail = `${this.state.outputTail || ""}${sourcePayload.text}`.slice(-32_000);
+ if (Array.isArray(sourcePayload.messages)) this.state.messages = sourcePayload.messages;
+ else {
+ const patchedMessages = applyStructuredMessagesPatch(this.state.messages, sourcePayload.messagesPatch);
+ if (patchedMessages) this.state.messages = patchedMessages;
+ }
+ if (Array.isArray(sourcePayload.subagents)) this.state.subagents = redact(sourcePayload.subagents);
+ if (sourcePayload.metadata && typeof sourcePayload.metadata === "object" && !Array.isArray(sourcePayload.metadata)) {
+ this.state.sessionMetadata = redactSessionMetadata(sourcePayload.metadata);
+ }
+ }
+ if (sourceType === "task.started") {
+ const id = sourcePayload.turnId || (sourcePayload.turn && sourcePayload.turn.id);
+ if (typeof id === "string") this.state.activeTurnId = id;
+ if (typeof sourcePayload.threadId === "string") this.state.activeThreadId = sourcePayload.threadId;
+ }
+ if (sourceType === "task.finished" || sourceType === "task.cancelled") {
+ this.state.activeTurnId = null;
+ }
+
+ if (sourceType === "connection.opened") {
+ this.state.app = "ready";
+ this.state.initialized = true;
+ this.state.hostConnected = true;
+ this.state.lastError = null;
+ } else if (sourceType === "connection.closed") {
+ // A replaced host socket can have one frame already queued in the
+ // transport. A stale close must not mark the newly connected host
+ // offline; acknowledge it so the old bridge does not retry forever.
+ if (this.hostClient !== client) {
+ if (sourceSeq !== undefined) {
+ this.sendControl(client, { v: 1, kind: "ack", sessionId: sessionId || "", seq: sourceSeq });
+ }
+ return;
+ }
+ this.state.app = "offline";
+ this.state.initialized = false;
+ this.state.activeTurnId = null;
+ this.state.outputTail = "";
+ this.state.messages = [];
+ this.state.subagents = [];
+ this.state.sessionMetadata = null;
+ this.state.executionStatus = null;
+ this.state.lastError = {
+ code: "app_unavailable",
+ message: typeof sourcePayload.message === "string"
+ ? redactString(sourcePayload.message)
+ : "VS Code host app-server disconnected",
+ retryable: true,
+ };
+ // This event is emitted by the authenticated host bridge when its local
+ // app-server exits. The relay socket remains usable, so clean only the
+ // app-scoped pending work here; transport close has its own handler.
+ this.handleHostAppUnavailable(client, sessionId, this.state.lastError.message);
+ }
+
+ // RelayHost emits normalized approval/input events and keeps the original
+ // app-server request id in payload. Store it centrally so exactly one
+ // browser response can be routed back to that host.
+ if (sourceType === "approval.requested"
+ || sourceType === "input.requested"
+ || sourceType === "server.requested"
+ || sourceType === "server.request") {
+ const requestId = sourcePayload.requestId;
+ if (requestId !== undefined) {
+ const key = jsonRpcIdKey(requestId);
+ this.clearPendingServerRequest(requestId);
+ const pending = {
+ appId: requestId,
+ method: typeof sourcePayload.method === "string" ? sourcePayload.method : sourceType,
+ params: sourcePayload.params || sourcePayload,
+ commandHash: typeof sourcePayload.commandHash === "string" ? sourcePayload.commandHash : undefined,
+ risk: typeof sourcePayload.risk === "string" ? sourcePayload.risk : undefined,
+ summary: typeof sourcePayload.summary === "string" ? sourcePayload.summary : undefined,
+ expiresAt: Number.isFinite(Number(sourcePayload.expiresAt)) ? Number(sourcePayload.expiresAt) : undefined,
+ createdAt: Date.now(),
+ source: "host",
+ hostClient: client,
+ };
+ // The VS Code adapter owns its local approval timer. Keeping a second
+ // timer in the relay would race the adapter's JSON-RPC response.
+ this.pendingServerRequests.set(key, pending);
+ }
+ }
+ if (sourceType === "approval.expired" || sourceType === "input.expired" || sourceType === "server.expired") {
+ const requestId = sourcePayload.requestId;
+ if (requestId !== undefined) {
+ const key = jsonRpcIdKey(requestId);
+ const pending = this.pendingServerRequests.get(key);
+ if (pending?.source === "host" && pending.hostClient === client) {
+ this.clearPendingServerRequest(pending.appId);
+ }
+ }
+ }
+ if (sourceType === "approval.resolved"
+ || sourceType === "input.resolved"
+ || sourceType === "server.responded"
+ || sourceType === "server.resolved") {
+ const requestId = sourcePayload.requestId;
+ if (requestId !== undefined) this.clearPendingServerRequest(requestId);
+ }
+
+ if (sourceType === "command.accepted" || sourceType === "command.rejected" || sourceType === "command.result") {
+ const commandId = sourcePayload.commandId;
+ const pending = commandId ? this.pendingHostCommands.get(String(commandId)) : undefined;
+ const ok = sourceType === "command.accepted" ? sourcePayload.ok !== false : sourcePayload.ok === true;
+ const resultPayload = {
+ commandId: commandId || null,
+ method: sourcePayload.method || null,
+ ok,
+ result: sourcePayload.result,
+ error: sourcePayload.error,
+ sourceSeq,
+ };
+ if (resultPayload.result && typeof resultPayload.result === "object") {
+ const result = resultPayload.result;
+ if (result.thread && typeof result.thread.id === "string") this.state.activeThreadId = result.thread.id;
+ if (typeof result.threadId === "string") this.state.activeThreadId = result.threadId;
+ if (typeof result.activeThreadId === "string") this.state.activeThreadId = result.activeThreadId;
+ if (typeof result.selectedThreadId === "string") this.state.activeThreadId = result.selectedThreadId;
+ if (result.turn && typeof result.turn.id === "string") this.state.activeTurnId = result.turn.id;
+ }
+ if (commandId) {
+ this.pendingHostCommands.delete(String(commandId));
+ // A late terminal frame from an app that already reported
+ // connection.closed must not repopulate the cache we just invalidated.
+ if (pending || this.state.app !== "offline") {
+ this.cacheCommandResult(
+ String(commandId),
+ resultPayload,
+ pending?.commandScope || client.commandScope || this.hostCommandScope,
+ );
+ }
+ }
+ const event = this.recordEvent("command.result", resultPayload, { sessionId });
+ if (pending?.kind === "server-response") {
+ const requestId = pending.responseRequestId ?? pending.requestId;
+ if (resultPayload.ok) {
+ this.clearPendingServerRequest(pending.requestId);
+ const responseEvent = this.recordEvent("server.responded", {
+ requestId,
+ method: pending.method,
+ ok: true,
+ }, { sessionId });
+ this.sendControl(pending.client, { type: "response.accepted", requestId, seq: responseEvent.seq });
+ } else {
+ this.sendControl(pending.client, {
+ type: "response.rejected",
+ requestId,
+ code: "host_rejected",
+ message: responseErrorMessage(resultPayload.error || "VS Code host rejected the response"),
+ retryable: true,
+ });
+ }
+ } else if (pending?.client) {
+ this.sendControl(pending.client, { type: "command.result", ...resultPayload, seq: event.seq });
+ }
+ return;
+ }
+
+ const payload = {
+ ...sourcePayload,
+ ...((sourceType === "approval.requested"
+ || sourceType === "input.requested"
+ || sourceType === "server.requested"
+ || sourceType === "server.request") && !sourcePayload.params
+ ? { params: sourcePayload }
+ : {}),
+ source: "vscode-host",
+ ...(sourceSeq !== undefined ? { sourceSeq } : {}),
+ ...(frame.raw !== undefined ? { raw: redact(frame.raw) } : {}),
+ };
+ const event = this.recordEvent(sourceType, payload, { sessionId });
+ // RelayHost sends event frames to its own relay transport and expects an
+ // ack. Acknowledge only after the frame has been accepted into our ring.
+ if (sourceSeq !== undefined) {
+ this.sendControl(client, { v: 1, kind: "ack", sessionId: sessionId || "", seq: sourceSeq });
+ }
+ return event;
+ }
+
+ handleHostAppUnavailable(client, sessionId, reason = "VS Code host app-server unavailable") {
+ // A delayed frame from an older host socket must never tear down the
+ // pending work or cache belonging to the currently authenticated host.
+ if (this.hostClient !== client) return;
+ const terminalError = {
+ code: "app_unavailable",
+ message: redactString(reason),
+ retryable: true,
+ };
+
+ // A local app-server crash invalidates both completed cache entries and
+ // in-flight host commands. Report in-flight commands as uncertain to the
+ // originating browser, but do not cache them: a retry after recovery must
+ // be explicit rather than silently replaying an unknown operation.
+ this.clearCommandResults();
+ for (const [commandId, pending] of [...this.pendingHostCommands]) {
+ if (pending.hostClient && pending.hostClient !== client) continue;
+ if (!pending.hostClient && pending.commandScope && pending.commandScope !== client.commandScope) continue;
+ this.pendingHostCommands.delete(commandId);
+ if (pending.kind === "server-response") {
+ this.sendControl(pending.client, {
+ type: "response.rejected",
+ requestId: pending.responseRequestId ?? pending.requestId,
+ code: terminalError.code,
+ message: terminalError.message,
+ retryable: true,
+ });
+ continue;
+ }
+ const payload = {
+ commandId,
+ method: pending.method || null,
+ ok: false,
+ uncertain: true,
+ retryable: true,
+ error: terminalError,
+ };
+ const event = this.recordEvent("command.result", payload, { sessionId });
+ if (pending.client) this.sendControl(pending.client, { type: "command.result", ...payload, seq: event.seq });
+ }
+
+ // Host approval/input requests are owned by the adapter, so the relay does
+ // not run a second expiry timer. Once the adapter reports its app process
+ // unavailable, remove every request tied to this host immediately.
+ for (const pending of [...this.pendingServerRequests.values()]) {
+ if (pending.source !== "host" || pending.hostClient !== client) continue;
+ this.clearPendingServerRequest(pending.appId);
+ const isInput = pending.method === "item/tool/requestUserInput"
+ || pending.method === "mcpServer/elicitation/request";
+ this.recordEvent(isInput ? "input.expired" : "approval.expired", {
+ requestId: pending.appId,
+ method: pending.method,
+ reason: terminalError.message,
+ error: terminalError,
+ }, { sessionId });
+ }
+ }
+
+ dispatchCommand(client, message) {
+ const commandId = String(message.commandId || "");
+ const method = String(message.method || "");
+ if (!commandId || commandId.length > 128) {
+ return this.commandRejected(client, commandId, "invalid_command_id", "commandId is required");
+ }
+ if (!ALLOWED_METHODS.has(method)) {
+ return this.commandRejected(client, commandId, "method_not_allowed", `Method ${method || "(empty)"} is not allowed`);
+ }
+ if (MUTATING_METHODS.has(method) && client.role !== "operator") {
+ this.auditAction(client, method, { commandId }, "denied");
+ return this.commandRejected(client, commandId, "forbidden", "Operator token required");
+ }
+ const cached = this.getCachedCommandResult(commandId);
+ if (cached) {
+ this.sendControl(client, { type: "command.result", ...cached, cached: true });
+ return { accepted: true, cached: true };
+ }
+ for (const pending of this.pendingHostCommands.values()) {
+ if (pending.commandId === commandId) {
+ this.sendControl(client, { type: "command.accepted", commandId, method, duplicate: true });
+ return { accepted: true, duplicate: true };
+ }
+ }
+ for (const pending of this.pendingAppRequests.values()) {
+ if (pending.commandId === commandId) {
+ this.sendControl(client, { type: "command.accepted", commandId, method, duplicate: true });
+ return { accepted: true, duplicate: true };
+ }
+ }
+
+ if (method === "initialize") {
+ if (!this.state.initialized) {
+ return this.commandRejected(client, commandId, "app_initializing", "Codex is still initializing", true);
+ }
+ const result = {
+ commandId,
+ method,
+ ok: true,
+ result: this.mode === "host"
+ ? { protocol: 1, mode: "host", hostConnected: this.state.hostConnected, sessionId: this.state.hostSessionId }
+ : this.initializedResult,
+ cachedAt: Date.now(),
+ };
+ this.cacheCommandResult(commandId, result, this.mode === "host" ? this.hostCommandScope : "embedded");
+ this.sendControl(client, { type: "command.result", ...result, cached: true });
+ return { accepted: true, cached: true };
+ }
+
+ if (!this.state.initialized) {
+ return this.commandRejected(client, commandId, "app_not_ready", "Codex app-server is not ready", true);
+ }
+ if (!message.params || typeof message.params !== "object" || Array.isArray(message.params)) {
+ return this.commandRejected(client, commandId, "invalid_params", "params must be an object");
+ }
+
+ const validationError = this.validateCommand(method, message.params);
+ if (validationError) return this.commandRejected(client, commandId, "invalid_params", validationError);
+
+ // This MVP exposes one active Codex turn per relay session. Keeping the
+ // check at the relay boundary prevents two browser operators from racing
+ // a turn start or steering an outdated turn id.
+ const turnStartPending = [...this.pendingAppRequests.values()].some((pending) => pending.method === "turn/start")
+ || [...this.pendingHostCommands.values()].some((pending) => pending.method === "turn/start");
+ if (method === "turn/start" && (this.state.activeTurnId || turnStartPending)) {
+ return this.commandRejected(client, commandId, "turn_active", "A Codex turn is already active", true);
+ }
+ if (method === "session/select" || method === "control/mode/set") {
+ const pendingMethod = method === "session/select" ? "session/select" : "control/mode/set";
+ const sessionSwitchPending = [...this.pendingHostCommands.values()].some((pending) => pending.method === pendingMethod)
+ || [...this.pendingAppRequests.values()].some((pending) => pending.method === pendingMethod);
+ if (sessionSwitchPending) {
+ return this.commandRejected(client, commandId, method === "session/select" ? "session_switch_pending" : "mode_switch_pending", method === "session/select" ? "A session switch is already in progress" : "A control mode switch is already in progress", true);
+ }
+ if (this.state.activeTurnId || this.pendingServerRequests.size) {
+ return this.commandRejected(client, commandId, method === "session/select" ? "session_busy" : "mode_busy", "The active session has a running turn or pending request", true);
+ }
+ }
+ if (method === "turn/steer" && this.state.activeTurnId && message.params.expectedTurnId !== this.state.activeTurnId) {
+ return this.commandRejected(client, commandId, "stale_turn", "expectedTurnId does not match the active turn", true);
+ }
+ if (method === "turn/interrupt" && this.state.activeTurnId && message.params.turnId !== this.state.activeTurnId) {
+ return this.commandRejected(client, commandId, "stale_turn", "turnId does not match the active turn", true);
+ }
+
+ // A connected VS Code bridge is the source of truth for the session. The
+ // relay never runs a second app-server request for the same command.
+ if (this.hostClient && this.hostClient.socket.readyState === WebSocket.OPEN) {
+ const hostFrame = {
+ v: 1,
+ kind: "command",
+ type: method,
+ commandId,
+ sessionId: this.hostClient.sessionId || undefined,
+ actor: { id: client.id || "web", role: client.role },
+ payload: message.params,
+ };
+ this.pendingHostCommands.set(commandId, {
+ commandId,
+ method,
+ client,
+ hostClient: this.hostClient,
+ commandScope: this.hostCommandScope || this.hostClient.commandScope || null,
+ createdAt: Date.now(),
+ });
+ try {
+ this.hostClient.socket.send(JSON.stringify(hostFrame));
+ this.auditAction(client, method, { commandId, params: message.params, target: "vscode-host" }, "forwarded");
+ this.sendControl(client, { type: "command.accepted", commandId, method, target: "vscode-host" });
+ return { accepted: true, commandId, method, target: "vscode-host" };
+ } catch (error) {
+ this.pendingHostCommands.delete(commandId);
+ return this.commandRejected(client, commandId, "host_unavailable", error.message, true);
+ }
+ }
+
+ const appId = this.nextAppRequestId("command");
+ try {
+ this.pendingAppRequests.set(jsonRpcIdKey(appId), {
+ kind: "command",
+ commandId,
+ method,
+ client,
+ createdAt: Date.now(),
+ });
+ this.sendToApp({ method, id: appId, params: message.params });
+ this.auditAction(client, method, { commandId, params: message.params }, "forwarded");
+ this.sendControl(client, { type: "command.accepted", commandId, method });
+ return { accepted: true, commandId, method };
+ } catch (error) {
+ this.pendingAppRequests.delete(jsonRpcIdKey(appId));
+ return this.commandRejected(client, commandId, error.code || "app_offline", error.message, error.retryable);
+ }
+ }
+
+ validateCommand(method, params) {
+ if (method === "control/mode/get") {
+ if (Object.keys(params).length > 0) return "control/mode/get does not accept parameters";
+ }
+ if (method === "control/mode/set") {
+ if (params.mode !== "sync" && params.mode !== "async") return "mode must be sync or async";
+ }
+ if (method === "session/list") {
+ if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 100)) {
+ return "limit must be an integer between 1 and 100";
+ }
+ }
+ if (method === "session/select") {
+ const threadId = params.threadId ?? params.conversationId;
+ if (typeof threadId !== "string" || !threadId.trim()) return "threadId is required";
+ if (threadId.length > 256) return "threadId is too long";
+ }
+ if (method === "thread/start") {
+ const allowedSandboxes = new Set(["read-only", "workspace-write", "danger-full-access"]);
+ if (params.sandbox != null && !allowedSandboxes.has(params.sandbox)) {
+ return "sandbox must be read-only, workspace-write, or danger-full-access";
+ }
+ if (params.cwd != null && typeof params.cwd !== "string") return "cwd must be a string";
+ }
+ if (method === "turn/start") {
+ if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required";
+ if (!Array.isArray(params.input) || params.input.length === 0) return "input must be a non-empty array";
+ }
+ if (method === "thread/settings/update") {
+ if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required";
+ const settings = params.threadSettings ?? params.settings;
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return "threadSettings must be an object";
+ if (settings.model !== undefined && typeof settings.model !== "string") return "threadSettings.model must be a string";
+ // `null` is the official value for clearing a model's reasoning effort
+ // (some models do not expose a selectable effort). Preserve it through
+ // the relay instead of rejecting a valid next-turn update.
+ if (settings.effort !== undefined && settings.effort !== null && typeof settings.effort !== "string") return "threadSettings.effort must be a string or null";
+ for (const key of ["sandboxPolicy", "approvalPolicy"]) {
+ const value = settings[key];
+ if (value !== undefined && value !== null && typeof value !== "string" && (typeof value !== "object" || Array.isArray(value))) {
+ return `threadSettings.${key} must be a string, object, or null`;
+ }
+ }
+ if (settings.approvalsReviewer !== undefined && settings.approvalsReviewer !== null && typeof settings.approvalsReviewer !== "string") {
+ return "threadSettings.approvalsReviewer must be a string or null";
+ }
+ if (settings.runtimeWorkspaceRoots !== undefined && settings.runtimeWorkspaceRoots !== null
+ && (!Array.isArray(settings.runtimeWorkspaceRoots) || !settings.runtimeWorkspaceRoots.every((entry) => typeof entry === "string"))) {
+ return "threadSettings.runtimeWorkspaceRoots must be an array of strings or null";
+ }
+ if (settings.permissions !== undefined && settings.permissions !== null
+ && (typeof settings.permissions !== "string" && (typeof settings.permissions !== "object" || Array.isArray(settings.permissions)))) {
+ return "threadSettings.permissions must be a string, object, or null";
+ }
+ }
+ if (method === "turn/steer") {
+ if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required";
+ if (typeof params.expectedTurnId !== "string" || !params.expectedTurnId) return "expectedTurnId is required";
+ if (!Array.isArray(params.input) || params.input.length === 0) return "input must be a non-empty array";
+ }
+ if (method === "turn/interrupt") {
+ if (typeof params.threadId !== "string" || !params.threadId) return "threadId is required";
+ if (typeof params.turnId !== "string" || !params.turnId) return "turnId is required";
+ }
+ return null;
+ }
+
+ commandRejected(client, commandId, code, message, retryable = false) {
+ const payload = { type: "command.rejected", commandId: commandId || null, code, message, retryable: Boolean(retryable) };
+ this.sendControl(client, payload);
+ return { accepted: false, ...payload };
+ }
+
+ scheduleServerRequestExpiry(requestId, pending) {
+ if (this.approvalTimeoutMs <= 0) return;
+ pending.timer = setTimeout(() => this.expireServerRequest(requestId), this.approvalTimeoutMs);
+ pending.timer.unref?.();
+ }
+
+ expireServerRequest(requestId) {
+ const pending = this.clearPendingServerRequest(requestId);
+ if (!pending) return;
+ const canonicalRequestId = pending.appId;
+ const isInput = pending.method === "item/tool/requestUserInput" || pending.method === "mcpServer/elicitation/request";
+ const reason = "Remote approval timed out";
+ this.recordEvent(isInput ? "input.expired" : "approval.expired", {
+ requestId: canonicalRequestId,
+ method: pending.method,
+ reason,
+ }, { sessionId: pending.hostClient?.sessionId || undefined });
+ this.auditAction({ id: "relay", role: "system" }, pending.method, { requestId: canonicalRequestId }, "expired");
+
+ const result = defaultServerResponse(pending.method, reason);
+ if (pending.source === "host") {
+ if (!pending.hostClient || pending.hostClient.socket.readyState !== WebSocket.OPEN) return;
+ const commandMethod = isInput ? "server.request.respond" : "approval.respond";
+ try {
+ pending.hostClient.socket.send(JSON.stringify({
+ v: 1,
+ kind: "command",
+ type: commandMethod,
+ commandId: randomId("timeout"),
+ sessionId: pending.hostClient.sessionId || undefined,
+ actor: { id: "relay", role: "system" },
+ payload: {
+ requestId: pending.appId,
+ decision: "deny",
+ response: result,
+ reason,
+ },
+ }));
+ } catch {
+ // The local adapter also has its own expiry deny; no retry is needed.
+ }
+ return;
+ }
+
+ try {
+ this.sendToApp({ id: pending.appId, result });
+ } catch {
+ // The process may have exited while the approval was pending.
+ }
+ }
+
+ clearPendingServerRequest(requestId) {
+ const key = jsonRpcIdKey(requestId);
+ const pending = this.pendingServerRequests.get(key);
+ if (pending?.timer) clearTimeout(pending.timer);
+ this.pendingServerRequests.delete(key);
+ return pending;
+ }
+
+ dispatchServerResponse(client, message) {
+ const requestId = message.requestId ?? message.id ?? "";
+ const requestKey = findTypedMapKey(this.pendingServerRequests, requestId, (value) => value?.appId, true);
+ if (client.role !== "operator") {
+ this.auditAction(client, "server-response", { requestId }, "denied");
+ this.sendControl(client, { type: "response.rejected", requestId, code: "forbidden", message: "Operator token required" });
+ return { accepted: false, code: "forbidden" };
+ }
+ const pending = this.pendingServerRequests.get(requestKey);
+ if (!pending) {
+ this.sendControl(client, { type: "response.rejected", requestId, code: "unknown_request", message: "Request is no longer pending" });
+ return { accepted: false, code: "unknown_request" };
+ }
+ if (!("result" in message) && !("error" in message)) {
+ this.sendControl(client, { type: "response.rejected", requestId, code: "invalid_response", message: "result or error is required" });
+ return { accepted: false, code: "invalid_response" };
+ }
+ const remoteResponseAllowed = SERVER_REQUEST_METHODS.has(pending.method)
+ || pending.method === "item/tool/requestUserInput"
+ || pending.method === "mcpServer/elicitation/request";
+ if (!remoteResponseAllowed) {
+ this.sendControl(client, {
+ type: "response.rejected",
+ requestId,
+ code: "unsupported_request",
+ message: "This app-server request must be handled by the host",
+ });
+ return { accepted: false, code: "unsupported_request" };
+ }
+
+ const normalizedResult = Object.prototype.hasOwnProperty.call(message, "result")
+ ? normalizeServerResponseForApp(pending.method, message.result)
+ : undefined;
+ if (Object.prototype.hasOwnProperty.call(message, "result")
+ && !isValidApprovalResponse(pending.method, normalizedResult)) {
+ this.sendControl(client, {
+ type: "response.rejected",
+ requestId,
+ code: "invalid_response",
+ message: "Unsupported or malformed approval decision",
+ });
+ return { accepted: false, code: "invalid_response" };
+ }
+ if (Object.prototype.hasOwnProperty.call(message, "requestedDecision")
+ && (pending.method === "item/commandExecution/requestApproval"
+ || pending.method === "item/fileChange/requestApproval"
+ || pending.method === "applyPatchApproval"
+ || pending.method === "execCommandApproval")) {
+ const requested = approvalDecisionKind(message.requestedDecision);
+ const actual = approvalDecisionForResult(normalizedResult);
+ if (!requested || requested !== actual) {
+ this.sendControl(client, {
+ type: "response.rejected",
+ requestId,
+ code: "decision_mismatch",
+ message: "Outer approval decision does not match the response",
+ });
+ return { accepted: false, code: "decision_mismatch" };
+ }
+ }
+
+ // Host-proxy mode uses the bridge's normalized command contract. Keep the
+ // original app-server request id in the payload, but do not forward an
+ // arbitrary JSON-RPC response as a relay command.
+ if (pending.source === "host") {
+ const result = normalizedResult;
+ const error = Object.prototype.hasOwnProperty.call(message, "error") ? message.error : undefined;
+ const method = pending.method || "";
+ const isInput = method === "item/tool/requestUserInput" || method === "mcpServer/elicitation/request";
+ const commandMethod = isInput ? "server.request.respond" : "approval.respond";
+ const decision = error
+ ? "deny"
+ : isInput
+ ? "allow"
+ : approvalDecisionForResult(result);
+ // Generate the host command from the canonical app-server id. This
+ // preserves the legacy `response-77` shape when a browser merely
+ // stringified a numeric id, while still suffixing ids when both typed
+ // variants are pending concurrently.
+ const commandId = responseCommandId(pending.appId, this.pendingServerRequests, this.pendingHostCommands);
+ const hostFrame = {
+ v: 1,
+ kind: "command",
+ type: commandMethod,
+ commandId,
+ sessionId: pending.hostClient?.sessionId || undefined,
+ actor: { id: client.id || "web", role: client.role },
+ payload: {
+ requestId: pending.appId,
+ decision,
+ ...(pending.commandHash ? { commandHash: pending.commandHash } : {}),
+ ...(result !== undefined ? { response: result } : {}),
+ ...(error !== undefined ? { reason: responseErrorMessage(error) } : {}),
+ },
+ };
+ const existing = this.pendingHostCommands.get(commandId);
+ if (existing?.kind === "server-response") {
+ this.sendControl(client, { type: "response.pending", requestId, commandId });
+ return { accepted: true, pending: true, requestId, commandId };
+ }
+ if (!pending.hostClient || pending.hostClient.socket.readyState !== WebSocket.OPEN) {
+ this.clearPendingServerRequest(pending.appId);
+ this.sendControl(client, { type: "response.rejected", requestId, code: "host_unavailable", message: "VS Code host is disconnected" });
+ return { accepted: false, code: "host_unavailable" };
+ }
+ try {
+ this.pendingHostCommands.set(commandId, {
+ kind: "server-response",
+ commandId,
+ // Keep the original app-server id for exact map cleanup. The
+ // browser-facing id may be a legacy stringified form of that id.
+ requestId: pending.appId,
+ responseRequestId: requestId,
+ method: pending.method,
+ client,
+ commandScope: this.hostCommandScope || pending.hostClient?.commandScope || null,
+ createdAt: Date.now(),
+ });
+ pending.hostClient.socket.send(JSON.stringify(hostFrame));
+ this.auditAction(client, pending.method, { requestId, target: "vscode-host", result, error }, "forwarded");
+ this.sendControl(client, { type: "response.pending", requestId, commandId });
+ return { accepted: true, pending: true, requestId, commandId };
+ } catch (sendError) {
+ this.pendingHostCommands.delete(commandId);
+ this.sendControl(client, { type: "response.rejected", requestId, code: "host_unavailable", message: sendError.message, retryable: true });
+ return { accepted: false, code: "host_unavailable" };
+ }
+ }
+
+ const appMessage = { id: pending.appId };
+ if ("result" in message) appMessage.result = normalizedResult;
+ else appMessage.error = message.error;
+ try {
+ this.sendToApp(appMessage);
+ this.clearPendingServerRequest(pending.appId);
+ this.auditAction(client, pending.method, { requestId, result: message.result, error: message.error }, "responded");
+ const event = this.recordEvent("server.responded", {
+ requestId,
+ method: pending.method,
+ ok: !message.error,
+ });
+ this.sendControl(client, { type: "response.accepted", requestId, seq: event.seq });
+ return { accepted: true, requestId };
+ } catch (error) {
+ this.sendControl(client, { type: "response.rejected", requestId, ...normalizeError(error) });
+ return { accepted: false, ...normalizeError(error) };
+ }
+ }
+
+ cacheCommandResult(commandId, payload, sessionScope) {
+ const key = String(commandId);
+ const scope = this.mode === "host"
+ ? (sessionScope || this.hostCommandScope || null)
+ : "embedded";
+ this.commandResults.set(key, { ...payload, cachedAt: Date.now() });
+ this.commandResultScopes.set(key, scope);
+ this.pruneCommandResults();
+ }
+
+ getCachedCommandResult(commandId) {
+ const key = String(commandId);
+ const cached = this.commandResults.get(key);
+ if (!cached) return null;
+ // A disconnect leaves the outcome unknown. Never replay that marker as a
+ // completed result; remove it so a retry can be forwarded to a reconnected
+ // host (or receive the normal offline error).
+ if (cached.uncertain) {
+ this.commandResults.delete(key);
+ this.commandResultScopes.delete(key);
+ return null;
+ }
+ const activeScope = this.mode === "host"
+ ? (this.hostClient && this.state.hostConnected ? this.hostCommandScope : null)
+ : "embedded";
+ // Host results are never replayed while disconnected. This also prevents
+ // an old result from leaking across a session-id change.
+ if (!activeScope || this.commandResultScopes.get(key) !== activeScope) return null;
+ return cached;
+ }
+
+ clearCommandResults() {
+ this.commandResults.clear();
+ this.commandResultScopes.clear();
+ }
+
+ pruneCommandResults() {
+ const cutoff = Date.now() - 15 * 60 * 1000;
+ for (const [key, value] of this.commandResults) {
+ if (value.cachedAt < cutoff) {
+ this.commandResults.delete(key);
+ this.commandResultScopes.delete(key);
+ }
+ }
+ while (this.commandResults.size > 1_000) {
+ const [firstKey] = this.commandResults.keys();
+ this.commandResults.delete(firstKey);
+ this.commandResultScopes.delete(firstKey);
+ }
+ }
+
+ sendControl(client, message) {
+ if (client.capture) client.capture.push(message);
+ if (!client.socket || client.socket.readyState !== WebSocket.OPEN) return;
+ const serialized = JSON.stringify(message);
+ const frameBytes = Buffer.byteLength(serialized, "utf8");
+ if (frameBytes > MAX_WS_PAYLOAD) {
+ client.socket.close(1009, "relay frame is too large");
+ return;
+ }
+ const bufferedBytes = Number(client.socket.bufferedAmount) || 0;
+ if (bufferedBytes + frameBytes > this.clientBufferedByteLimit) {
+ client.socket.close(1013, "client is too slow");
+ return;
+ }
+ client.socket.send(serialized);
+ }
+
+ snapshot() {
+ // These projections used to be serialized both inside `state` and again
+ // at the top level, nearly doubling every long-history control frame.
+ // Keep the compact lifecycle state nested and one authoritative transcript
+ // projection at the stable top-level protocol fields.
+ const {
+ outputTail,
+ messages,
+ subagents,
+ sessionMetadata,
+ executionStatus,
+ ...state
+ } = this.state;
+ return {
+ protocol: 1,
+ latestSeq: this.nextSeq,
+ state,
+ clients: [...this.clients]
+ .filter((client) => client.authenticated)
+ .map((client) => ({ id: client.id, role: client.role })),
+ pendingRequests: [...this.pendingServerRequests.values()].map((request) => ({
+ requestId: request.appId,
+ method: request.method,
+ params: request.params,
+ ...(request.commandHash ? { commandHash: request.commandHash } : {}),
+ ...(request.risk ? { risk: request.risk } : {}),
+ ...(request.summary ? { summary: request.summary } : {}),
+ ...(request.expiresAt ? { expiresAt: request.expiresAt } : {}),
+ createdAt: request.createdAt,
+ })),
+ outputTail: outputTail || "",
+ messages: Array.isArray(messages) ? messages : [],
+ subagents: Array.isArray(subagents) ? subagents : [],
+ ...(sessionMetadata ? { metadata: sessionMetadata } : {}),
+ status: executionStatus,
+ executionStatus,
+ };
+ }
+
+ tokenFromRequest(request) {
+ const authorization = request.headers.authorization || "";
+ if (/^Bearer\s+/i.test(authorization)) return authorization.replace(/^Bearer\s+/i, "");
+ return request.headers["x-codex-token"] || "";
+ }
+
+ authenticateHttp(request) {
+ const role = this.roleForToken(this.tokenFromRequest(request));
+ if (role) return role;
+ if (!this.authRequired && isLoopbackAddress(request.socket?.remoteAddress)) return "operator";
+ return null;
+ }
+
+ async handleHttp(request, response) {
+ const base = `http://${request.headers.host || "localhost"}`;
+ let requestUrl;
+ try {
+ requestUrl = new URL(request.url, base);
+ } catch {
+ jsonResponse(response, 400, { error: "invalid_url" });
+ return;
+ }
+
+ if (!this.authRequired && !isLoopbackRequestHost(request)) {
+ jsonResponse(response, 403, { error: "loopback_host_required" });
+ return;
+ }
+
+ if (request.method === "GET" && requestUrl.pathname === "/api/health") {
+ jsonResponse(response, this.state.app === "offline" ? 503 : 200, {
+ ok: this.state.app !== "offline",
+ app: this.state.app,
+ initialized: this.state.initialized,
+ authRequired: this.authRequired,
+ latestSeq: this.nextSeq,
+ });
+ return;
+ }
+
+ if (requestUrl.pathname.startsWith("/api/")) {
+ const role = this.authenticateHttp(request);
+ if (!role) {
+ jsonResponse(response, 401, { error: "unauthorized" }, { "WWW-Authenticate": "Bearer" });
+ return;
+ }
+
+ if (request.method === "POST" && !isAllowedHttpOrigin(request)) {
+ jsonResponse(response, 403, { error: "origin_not_allowed" });
+ return;
+ }
+
+ if (request.method === "GET" && requestUrl.pathname === "/api/state") {
+ jsonResponse(response, 200, { role, ...this.snapshot(), audit: this.audit.slice(-50) });
+ return;
+ }
+ if (request.method === "GET" && requestUrl.pathname === "/api/events") {
+ const fromSeq = Number(requestUrl.searchParams.get("fromSeq") || 0);
+ jsonResponse(response, 200, {
+ latestSeq: this.nextSeq,
+ events: this.events.filter((event) => event.seq > fromSeq),
+ });
+ return;
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/api/command") {
+ try {
+ const body = await readJson(request);
+ const client = { id: "http", role, authenticated: true, capture: [] };
+ const result = this.dispatchCommand(client, { type: "command", ...body });
+ jsonResponse(response, result.accepted ? 202 : 400, { ...result, messages: client.capture });
+ } catch (error) {
+ jsonResponse(response, error.statusCode || 400, { error: normalizeError(error) });
+ }
+ return;
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/api/respond") {
+ try {
+ const body = await readJson(request);
+ const client = { id: "http", role, authenticated: true, capture: [] };
+ const result = this.dispatchServerResponse(client, { type: "respond", ...body });
+ jsonResponse(response, result.accepted ? 202 : 400, { ...result, messages: client.capture });
+ } catch (error) {
+ jsonResponse(response, error.statusCode || 400, { error: normalizeError(error) });
+ }
+ return;
+ }
+ jsonResponse(response, 404, { error: "not_found" });
+ return;
+ }
+
+ this.serveStatic(request, response, requestUrl.pathname);
+ }
+
+ serveStatic(request, response, pathname) {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ response.writeHead(405, { Allow: "GET, HEAD" });
+ response.end();
+ return;
+ }
+ let relativePath;
+ try {
+ relativePath = pathname === "/" ? "index.html" : decodeURIComponent(pathname).replace(/^\/+/, "");
+ } catch {
+ // A malformed percent escape must be an ordinary client error, not an
+ // uncaught exception from the HTTP request handler.
+ response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
+ response.end("Invalid URL");
+ return;
+ }
+ const filePath = path.resolve(PUBLIC_ROOT, relativePath);
+ if (!filePath.startsWith(`${PUBLIC_ROOT}${path.sep}`) && filePath !== path.join(PUBLIC_ROOT, "index.html")) {
+ response.writeHead(403);
+ response.end("Forbidden");
+ return;
+ }
+ fs.stat(filePath, (error, stat) => {
+ if (error || !stat.isFile()) {
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Not found");
+ return;
+ }
+ response.writeHead(200, {
+ "Content-Type": contentType(filePath),
+ "Content-Length": stat.size,
+ "Cache-Control": "no-store",
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "no-referrer",
+ "Content-Security-Policy": "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'self'",
+ });
+ if (request.method === "HEAD") response.end();
+ else fs.createReadStream(filePath).pipe(response);
+ });
+ }
+}
+
+function isObjectPayload(value) {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+function firstObject(...values) {
+ return values.find((value) => isObjectPayload(value)) || null;
+}
+
+function commandMethodFromFrame(frame) {
+ const nested = isObjectPayload(frame.command) ? frame.command : null;
+ if (typeof frame.method === "string" && frame.method.trim()) return frame.method.trim();
+ if (typeof nested?.type === "string" && nested.type.trim()) return nested.type.trim();
+ if (typeof frame.kind === "string" && (frame.kind === "command" || frame.kind === "response") && typeof frame.type === "string") {
+ if (frame.type !== "command" && frame.type !== "respond" && frame.type !== "server-response") return frame.type.trim();
+ }
+ if (typeof frame.type === "string" && frame.type !== "command" && frame.type !== "respond" && frame.type !== "server-response") {
+ return frame.type.trim();
+ }
+ return "";
+}
+
+function normalizeWireMethod(method) {
+ const value = String(method || "").trim();
+ const aliases = {
+ "control.mode.get": "control/mode/get",
+ "controlmode.get": "control/mode/get",
+ "mode.get": "control/mode/get",
+ "control.mode.set": "control/mode/set",
+ "controlmode.set": "control/mode/set",
+ "mode.set": "control/mode/set",
+ "thread.start": "thread/start",
+ "session.new": "session/new",
+ "sessionnew": "session/new",
+ "thread.new": "session/new",
+ "threadnew": "session/new",
+ "session/new": "session/new",
+ "thread/new": "session/new",
+ "thread.settings.update": "thread/settings/update",
+ "threadsettings.update": "thread/settings/update",
+ "session.list": "session/list",
+ "thread.list": "session/list",
+ "session.select": "session/select",
+ "session.switch": "session/select",
+ "thread.select": "session/select",
+ "thread.attach": "session/select",
+ "turn.start": "turn/start",
+ "turn.steer": "turn/steer",
+ "turn.interrupt": "turn/interrupt",
+ };
+ return aliases[value.toLowerCase()] || value;
+}
+
+function commandIdFromFrame(frame) {
+ const nested = isObjectPayload(frame.command) ? frame.command : null;
+ const value = frame.commandId ?? nested?.commandId ?? (typeof frame.id === "string" ? frame.id : undefined);
+ return value === undefined || value === null ? "" : String(value);
+}
+
+function normalizeBrowserCommand(frame) {
+ const method = normalizeWireMethod(commandMethodFromFrame(frame));
+ const type = frame.type;
+ const hasCommandEnvelope = frame.kind === "command"
+ || type === "command"
+ || (frame.kind === undefined && (ALLOWED_METHODS.has(method) || REMOTE_RESPONSE_METHODS.has(method)));
+ if (!hasCommandEnvelope || !method) return null;
+
+ const nested = isObjectPayload(frame.command) ? frame.command : null;
+ const params = firstObject(frame.params, frame.payload, nested?.params, nested?.payload) || {};
+ return {
+ type: "command",
+ commandId: commandIdFromFrame(frame),
+ method,
+ params,
+ };
+}
+
+function approvalWireDecision(decision) {
+ // Keep the caller's decision intact until dispatch knows the target
+ // app-server method. Legacy approval methods use `approved`/`abort`, while
+ // v2 methods use `accept`/`cancel`; method-aware normalization handles the
+ // conversion without discarding amendment tags.
+ return decision;
+}
+
+function normalizeBrowserResponse(frame, hintedMethod) {
+ const type = typeof frame.type === "string" ? frame.type : "";
+ const method = hintedMethod || commandMethodFromFrame(frame);
+ const isLegacy = type === "respond" || type === "server-response";
+ const isResponse = isLegacy
+ || frame.kind === "response"
+ || REMOTE_RESPONSE_METHODS.has(method);
+ if (!isResponse) return null;
+
+ const nested = isObjectPayload(frame.command) ? frame.command : null;
+ const payload = firstObject(frame.payload, frame.params, nested?.payload, nested?.params) || (isLegacy ? {} : frame);
+ const requestId = frame.requestId ?? payload.requestId ?? (typeof frame.id === "number" || typeof frame.id === "string" ? frame.id : "");
+ const response = { type: "respond", requestId };
+ if (payload.decision !== undefined) response.requestedDecision = payload.decision;
+
+ if (Object.prototype.hasOwnProperty.call(frame, "result")) {
+ response.result = frame.result;
+ } else if (Object.prototype.hasOwnProperty.call(frame, "error")) {
+ response.error = frame.error;
+ } else if (Object.prototype.hasOwnProperty.call(payload, "result")) {
+ response.result = payload.result;
+ } else if (Object.prototype.hasOwnProperty.call(payload, "error")) {
+ response.error = payload.error;
+ } else if (payload.response !== undefined) {
+ response.result = payload.response;
+ } else if (method === "input.respond" || method === "server.request.respond") {
+ if (payload.answers !== undefined) response.result = { answers: payload.answers };
+ else {
+ const custom = {};
+ for (const [key, value] of Object.entries(payload)) {
+ if (!["v", "kind", "type", "method", "commandId", "id", "sessionId", "actor", "requestId", "reason", "params", "payload", "command"].includes(key)) custom[key] = value;
+ }
+ if (Object.keys(custom).length) response.result = custom;
+ }
+ } else if (payload.decision !== undefined) {
+ response.result = { decision: approvalWireDecision(payload.decision) };
+ }
+ return response;
+}
+
+async function main() {
+ const relay = new CodexRelay();
+ const address = await relay.start();
+ const displayHost = address.host === "::" || address.host === "0.0.0.0" ? "127.0.0.1" : address.host;
+ process.stdout.write(`Codex Remote Collab: http://${displayHost}:${address.port}\n`);
+ if (relay.authRequired) {
+ process.stdout.write(`Host token: ${relay.hostToken}\n`);
+ process.stdout.write(`Operator token: ${relay.operatorToken}\n`);
+ process.stdout.write(`Viewer token: ${relay.viewerToken}\n`);
+ process.stdout.write("Keep these tokens private. Use TLS before exposing this relay outside a trusted network.\n");
+ } else {
+ process.stdout.write("Authentication: disabled for loopback connections (set CODEX_REMOTE_AUTH=required to enable tokens).\n");
+ }
+
+ const shutdown = async () => {
+ await relay.stop();
+ process.exit(0);
+ };
+ process.once("SIGINT", shutdown);
+ process.once("SIGTERM", shutdown);
+}
+
+if (require.main === module) {
+ main().catch((error) => {
+ process.stderr.write(`${error.stack || error}\n`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = {
+ ALLOWED_METHODS,
+ CodexRelay,
+ SERVER_REQUEST_METHODS,
+ redact,
+};
diff --git a/aether-vscodex/test/adapter-safety.test.js b/aether-vscodex/test/adapter-safety.test.js
new file mode 100644
index 000000000..99e1ecece
--- /dev/null
+++ b/aether-vscodex/test/adapter-safety.test.js
@@ -0,0 +1,745 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const test = require("node:test");
+
+const { CodexAgentAdapter } = require("../vscode-extension/dist/codexAgentAdapter.js");
+const { RelayHost } = require("../vscode-extension/dist/relayHost.js");
+
+class FakeRpc {
+ responses = [];
+ requests = [];
+ notificationListener;
+ requestListener;
+ exitListener;
+ overrides;
+
+ constructor(overrides = {}) {
+ this.overrides = overrides;
+ }
+
+ get running() {
+ return true;
+ }
+
+ async start() {}
+
+ async request(method, params) {
+ this.requests.push({ method, params });
+ if (Object.prototype.hasOwnProperty.call(this.overrides, method)) {
+ const override = this.overrides[method];
+ return typeof override === "function" ? override(params) : override;
+ }
+ if (method === "initialize") return { userAgent: "test", codexHome: "/tmp/codex" };
+ if (method === "thread/start") return { thread: { id: "thread-test" }, cwd: "/tmp" };
+ if (method === "turn/start") return { turn: { id: "turn-test" } };
+ if (method === "turn/steer") return { turn: { id: "turn-test" } };
+ if (method === "turn/interrupt") return {};
+ throw new Error(`unexpected request ${method}`);
+ }
+
+ notify() {}
+
+ respond(id, result) {
+ this.responses.push({ id, result });
+ }
+
+ respondError(id, code, message) {
+ this.responses.push({ id, error: { code, message } });
+ }
+
+ onNotification(listener) {
+ this.notificationListener = listener;
+ return { dispose: () => undefined };
+ }
+
+ onServerRequest(listener) {
+ this.requestListener = listener;
+ return { dispose: () => undefined };
+ }
+
+ onExit(listener) {
+ this.exitListener = listener;
+ return { dispose: () => undefined };
+ }
+
+ close() {}
+
+ emitRequest(request) {
+ this.requestListener(request);
+ }
+
+ emitNotification(notification) {
+ this.notificationListener(notification);
+ }
+}
+
+class FakeRelay {
+ frames = [];
+ listeners = new Set();
+
+ async connect() {}
+
+ send(frame) {
+ this.frames.push(frame);
+ }
+
+ onMessage(listener) {
+ this.listeners.add(listener);
+ return { dispose: () => this.listeners.delete(listener) };
+ }
+
+ close() {}
+}
+
+test("CodexAgentAdapter keeps numeric and string approval ids distinct", async () => {
+ const rpc = new FakeRpc();
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ await adapter.start();
+
+ rpc.emitRequest({
+ id: 1,
+ method: "item/commandExecution/requestApproval",
+ params: { threadId: "t", turnId: "u", itemId: "n", command: "echo number" },
+ });
+ rpc.emitRequest({
+ id: "1",
+ method: "item/commandExecution/requestApproval",
+ params: { threadId: "t", turnId: "u", itemId: "s", command: "echo string" },
+ });
+
+ const snapshot = await adapter.snapshot();
+ assert.deepEqual(snapshot.pendingApprovals.map((entry) => entry.requestId), [1, "1"]);
+ await adapter.respondApproval(1, "deny");
+ await adapter.respondApproval("1", "deny");
+ assert.deepEqual(rpc.responses.map((entry) => entry.id), [1, "1"]);
+ assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
+ await adapter.dispose();
+});
+
+test("commandActions are included in high-risk approval classification", async () => {
+ const rpc = new FakeRpc();
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ await adapter.start();
+ rpc.emitRequest({
+ id: 2,
+ method: "item/commandExecution/requestApproval",
+ params: {
+ threadId: "t",
+ turnId: "u",
+ itemId: "actions",
+ command: null,
+ commandActions: [{ type: "unknown", command: "sudo rm -rf /" }],
+ },
+ });
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.pendingApprovals[0].risk, "high");
+ await adapter.respondApproval(2, "deny");
+ await adapter.dispose();
+});
+
+test("output snapshots stay redacted and interrupt clears the active turn", async () => {
+ const rpc = new FakeRpc();
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ await adapter.start();
+ await adapter.startThread({});
+ await adapter.startTurn({ text: "hello" });
+ assert.equal((await adapter.snapshot()).turnId, "turn-test");
+
+ rpc.emitNotification({
+ method: "item/agentMessage/delta",
+ params: { delta: "credential Bearer abcdefghijklmnop" },
+ });
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.outputTail.includes("Bearer abcdefghijklmnop"), false);
+ assert.match(snapshot.outputTail, /\[REDACTED\]/);
+
+ await adapter.interruptTurn({});
+ const afterInterrupt = await adapter.snapshot();
+ assert.equal(afterInterrupt.turnId, null);
+ assert.equal(afterInterrupt.state, "idle");
+ await adapter.dispose();
+});
+
+test("async adapter lists app-server threads and exposes the model catalog", async () => {
+ const rpc = new FakeRpc({
+ "model/list": {
+ data: [{ id: "model-1", model: "gpt-5.6-sol", displayName: "5.6 Sol", hidden: false }],
+ nextCursor: null,
+ },
+ "thread/list": {
+ data: [
+ {
+ id: "thread-recent",
+ name: null,
+ preview: "Inspect the workspace\nwith detail",
+ cwd: "/tmp/workspace",
+ createdAt: 1_700_000_000,
+ updatedAt: 1_700_000_100,
+ status: { type: "idle" },
+ source: "vscode",
+ },
+ ],
+ nextCursor: "next-page",
+ backwardsCursor: null,
+ },
+ });
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ await adapter.start();
+
+ const result = await adapter.listSessions({ limit: 500, query: "workspace", sortKey: "invalid" });
+ assert.equal(result.sessions[0].threadId, "thread-recent");
+ assert.equal(result.sessions[0].title, "Inspect the workspace with detail");
+ assert.equal(result.sessions[0].updatedAtMs, 1_700_000_100_000);
+ assert.equal(result.nextCursor, "next-page");
+ const listRequest = rpc.requests.find((entry) => entry.method === "thread/list");
+ assert.deepEqual(listRequest.params, {
+ limit: 100,
+ sortKey: "updated_at",
+ sortDirection: "desc",
+ searchTerm: "workspace",
+ });
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.metadata.mode, "async");
+ assert.equal(snapshot.metadata.availableModels[0].model, "gpt-5.6-sol");
+ await adapter.dispose();
+});
+
+test("async adapter projects live token usage notifications into metadata and snapshots", async () => {
+ const rpc = new FakeRpc({
+ "model/list": { data: [], nextCursor: null },
+ });
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ const events = [];
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ await adapter.startThread({});
+
+ rpc.emitNotification({
+ method: "thread/tokenUsage/updated",
+ params: {
+ threadId: "thread-test",
+ // A usage update may arrive after the turn has completed. It must not
+ // make the adapter report that historical turn as active again.
+ turnId: "turn-finished",
+ tokenUsage: {
+ total: {
+ totalTokens: 1_200,
+ inputTokens: 800,
+ cachedInputTokens: 100,
+ cacheWriteInputTokens: 20,
+ outputTokens: 300,
+ reasoningOutputTokens: 80,
+ },
+ last: {
+ totalTokens: 450,
+ inputTokens: 300,
+ cachedInputTokens: 40,
+ cacheWriteInputTokens: 10,
+ outputTokens: 100,
+ reasoningOutputTokens: 40,
+ },
+ modelContextWindow: 128_000,
+ },
+ },
+ });
+
+ const expected = {
+ total: {
+ totalTokens: 1_200,
+ inputTokens: 800,
+ cachedInputTokens: 100,
+ cacheWriteInputTokens: 20,
+ outputTokens: 300,
+ reasoningOutputTokens: 80,
+ },
+ last: {
+ totalTokens: 450,
+ inputTokens: 300,
+ cachedInputTokens: 40,
+ cacheWriteInputTokens: 10,
+ outputTokens: 100,
+ reasoningOutputTokens: 40,
+ },
+ modelContextWindow: 128_000,
+ };
+ const snapshot = await adapter.snapshot();
+ assert.deepEqual(snapshot.metadata.tokenUsage, expected);
+ assert.deepEqual(snapshot.metadata.latestTokenUsageInfo, expected);
+ assert.equal(snapshot.turnId, null);
+
+ const usageEvent = events.find((event) => event.raw?.method === "thread/tokenUsage/updated");
+ assert.ok(usageEvent);
+ assert.deepEqual(usageEvent.payload.tokenUsage, expected);
+ assert.deepEqual(usageEvent.payload.latestTokenUsageInfo, expected);
+ // Keep the raw diagnostic envelope redacted while exposing only the safe
+ // numeric projection to the browser.
+ assert.equal(usageEvent.raw.params.tokenUsage, "[REDACTED]");
+
+ rpc.emitNotification({
+ method: "thread/tokenUsage/updated",
+ params: {
+ threadId: "thread-test",
+ turnId: "turn-finished",
+ tokenUsage: { total: { inputTokens: -1 } },
+ },
+ });
+ assert.deepEqual((await adapter.snapshot()).metadata.tokenUsage, expected);
+ await adapter.dispose();
+});
+
+test("async adapter resumes a thread with structured history and ignores late notifications", async () => {
+ const thread = {
+ id: "thread-selected",
+ name: "Selected thread",
+ preview: "hello",
+ cwd: "/tmp/selected",
+ createdAt: 1_700_000_000,
+ updatedAt: 1_700_000_010,
+ status: { type: "idle" },
+ turns: [{
+ id: "turn-history",
+ status: "completed",
+ startedAt: 1_700_000_001,
+ completedAt: 1_700_000_004,
+ durationMs: 3_000,
+ items: [
+ { type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] },
+ { type: "reasoning", id: "reason-1", summary: ["Checking files"], content: [] },
+ { type: "commandExecution", id: "command-1", command: "pwd", cwd: "/tmp/selected", status: "completed", aggregatedOutput: "/tmp/selected\n", exitCode: 0, durationMs: 50, commandActions: [] },
+ { type: "agentMessage", id: "agent-1", text: "Done", phase: "final_answer" },
+ ],
+ }],
+ };
+ const rpc = new FakeRpc({
+ "model/list": { data: [{ id: "model-1", model: "gpt-5.6-sol" }], nextCursor: null },
+ "thread/resume": {
+ thread,
+ model: "gpt-5.6-sol",
+ modelProvider: "openai",
+ serviceTier: null,
+ cwd: "/tmp/selected",
+ approvalPolicy: "on-request",
+ approvalsReviewer: "user",
+ sandbox: { type: "workspaceWrite" },
+ reasoningEffort: "high",
+ },
+ });
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ const events = [];
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+
+ const result = await adapter.selectSession({ threadId: "thread-selected" });
+ assert.equal(result.threadId, "thread-selected");
+ let snapshot = await adapter.snapshot();
+ assert.equal(snapshot.messages.length, 4);
+ assert.deepEqual(snapshot.messages.map((message) => message.kind), ["user", "reasoning", "tool", "assistant"]);
+ assert.equal(snapshot.messages[2].output, "/tmp/selected\n");
+ assert.equal(snapshot.metadata.title, "Selected thread");
+ assert.equal(snapshot.metadata.threadSettings.effort, "high");
+ assert.equal(snapshot.metadata.historyComplete, true);
+ assert.equal(snapshot.status.turnStatus, "completed");
+ assert.match(snapshot.outputTail, /Done/);
+ assert.ok(events.some((event) => event.type === "output.snapshot" && event.payload.historyComplete === true));
+ const authoritative = events.find((event) => event.type === "session.snapshot");
+ assert.equal(authoritative.payload.threadId, "thread-selected");
+ assert.equal(authoritative.payload.metadata.model, "gpt-5.6-sol");
+ assert.equal(authoritative.payload.messages.length, 4);
+
+ rpc.emitNotification({
+ method: "item/completed",
+ params: {
+ threadId: "thread-old",
+ turnId: "turn-old",
+ completedAtMs: Date.now(),
+ item: { type: "agentMessage", id: "late-old", text: "wrong thread" },
+ },
+ });
+ rpc.emitNotification({
+ method: "item/completed",
+ params: {
+ threadId: "thread-selected",
+ turnId: "turn-live",
+ completedAtMs: Date.now(),
+ item: { type: "agentMessage", id: "current-item", text: "current thread" },
+ },
+ });
+ snapshot = await adapter.snapshot();
+ assert.equal(snapshot.messages.some((message) => message.itemId === "late-old"), false);
+ assert.equal(snapshot.messages.some((message) => message.itemId === "current-item"), true);
+ await adapter.dispose();
+});
+
+test("async adapter hydrates paginated turns and items into chronological complete history", async () => {
+ const threadId = "thread-paged-history";
+ const userItem = (id, text) => ({
+ type: "userMessage",
+ id,
+ clientId: null,
+ content: [{ type: "text", text, text_elements: [] }],
+ });
+ const assistantItem = (id, text) => ({
+ type: "agentMessage",
+ id,
+ text,
+ phase: "final_answer",
+ });
+ const earlyUser = userItem("early-user", "first question");
+ const rpc = new FakeRpc({
+ "model/list": { data: [], nextCursor: null },
+ "thread/resume": {
+ thread: {
+ id: threadId,
+ name: "Paged history",
+ preview: "first question",
+ cwd: "/tmp/paged",
+ createdAt: 50,
+ updatedAt: 350,
+ historyMode: "paginated",
+ status: { type: "idle" },
+ turns: [],
+ },
+ model: "gpt-5.6-sol",
+ cwd: "/tmp/paged",
+ initialTurnsPage: {
+ data: [{
+ id: "turn-late",
+ status: "completed",
+ startedAt: 300,
+ completedAt: 310,
+ itemsView: "full",
+ items: [userItem("late-user", "third question"), assistantItem("late-agent", "third answer")],
+ }],
+ nextCursor: "turn-page-2",
+ backwardsCursor: null,
+ },
+ },
+ "thread/turns/list": (params) => {
+ if (params.cursor === "turn-page-2") {
+ return {
+ data: [{
+ id: "turn-early",
+ status: "completed",
+ startedAt: 100,
+ completedAt: 110,
+ itemsView: "summary",
+ items: [earlyUser],
+ }],
+ nextCursor: "turn-page-3",
+ backwardsCursor: null,
+ };
+ }
+ assert.equal(params.cursor, "turn-page-3");
+ return {
+ data: [{
+ id: "turn-middle",
+ status: "completed",
+ startedAt: 200,
+ completedAt: 210,
+ itemsView: "full",
+ items: [userItem("middle-user", "second question"), assistantItem("middle-agent", "second answer")],
+ }],
+ nextCursor: null,
+ backwardsCursor: null,
+ };
+ },
+ "thread/items/list": (params) => {
+ assert.equal(params.turnId, "turn-early");
+ return {
+ data: [
+ // The summary row is repeated by the full item page; hydration must
+ // de-duplicate it while adding the omitted assistant response.
+ { turnId: "turn-early", item: earlyUser },
+ { turnId: "turn-early", item: assistantItem("early-agent", "first answer") },
+ ],
+ nextCursor: null,
+ backwardsCursor: null,
+ };
+ },
+ });
+ const events = [];
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ await adapter.selectSession({ threadId });
+
+ const resume = rpc.requests.find((entry) => entry.method === "thread/resume");
+ assert.deepEqual(resume.params, {
+ threadId,
+ excludeTurns: true,
+ initialTurnsPage: { limit: 100, sortDirection: "asc", itemsView: "full" },
+ });
+ const turnPages = rpc.requests.filter((entry) => entry.method === "thread/turns/list");
+ assert.deepEqual(turnPages.map((entry) => entry.params.cursor), ["turn-page-2", "turn-page-3"]);
+ assert.ok(turnPages.every((entry) => entry.params.threadId === threadId
+ && entry.params.limit === 100
+ && entry.params.sortDirection === "asc"
+ && entry.params.itemsView === "full"));
+ const itemPages = rpc.requests.filter((entry) => entry.method === "thread/items/list");
+ assert.deepEqual(itemPages.map((entry) => entry.params), [{
+ threadId,
+ turnId: "turn-early",
+ limit: 100,
+ sortDirection: "asc",
+ }]);
+ assert.equal(rpc.requests.some((entry) => entry.method === "thread/read"), false);
+
+ const snapshot = await adapter.snapshot();
+ assert.deepEqual(snapshot.messages.map((message) => [message.turnId, message.text]), [
+ ["turn-early", "first question"],
+ ["turn-early", "first answer"],
+ ["turn-middle", "second question"],
+ ["turn-middle", "second answer"],
+ ["turn-late", "third question"],
+ ["turn-late", "third answer"],
+ ]);
+ assert.equal(snapshot.metadata.historyComplete, true);
+ const outputSnapshot = events.find((event) => event.type === "output.snapshot");
+ assert.equal(outputSnapshot.payload.historyComplete, true);
+ assert.deepEqual(outputSnapshot.payload.messages.map((message) => message.text), [
+ "first question",
+ "first answer",
+ "second question",
+ "second answer",
+ "third question",
+ "third answer",
+ ]);
+ await adapter.dispose();
+});
+
+test("async adapter falls back to thread/read when resume omits existing history", async () => {
+ const metadataThread = {
+ id: "thread-paginated",
+ preview: "existing conversation",
+ cwd: "/tmp/project",
+ createdAt: 1_700_000_000,
+ updatedAt: 1_700_000_100,
+ status: { type: "idle" },
+ turns: [],
+ };
+ const rpc = new FakeRpc({
+ "model/list": { data: [], nextCursor: null },
+ "thread/resume": { thread: metadataThread, model: "gpt-5.6-sol", cwd: "/tmp/project" },
+ "thread/read": {
+ thread: {
+ ...metadataThread,
+ turns: [{
+ id: "turn-read",
+ status: "completed",
+ items: [{ type: "agentMessage", id: "read-agent", text: "hydrated history" }],
+ }],
+ },
+ },
+ });
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ await adapter.start();
+ await adapter.selectSession({ threadId: "thread-paginated" });
+ const read = rpc.requests.find((entry) => entry.method === "thread/read");
+ assert.deepEqual(read.params, { threadId: "thread-paginated", includeTurns: true });
+ assert.equal((await adapter.snapshot()).messages[0].text, "hydrated history");
+ await adapter.dispose();
+});
+
+test("async adapter starts new sessions and sends flat durable thread settings", async () => {
+ const rpc = new FakeRpc({
+ "model/list": { data: [], nextCursor: null },
+ "thread/start": {
+ thread: { id: "thread-new", preview: "", cwd: "/tmp/new", status: { type: "idle" }, turns: [] },
+ model: "gpt-5.6-sol",
+ cwd: "/tmp/new",
+ reasoningEffort: "medium",
+ },
+ "thread/settings/update": { ok: true },
+ });
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0, defaultCwd: "/tmp/default" }, rpc);
+ await adapter.start();
+ await adapter.newSession({});
+ await adapter.updateThreadSettings({
+ threadSettings: {
+ model: "gpt-5.6-terra",
+ effort: "high",
+ approvalPolicy: "on-request",
+ approvalsReviewer: "user",
+ sandboxPolicy: "workspace-write",
+ permissions: ":workspace",
+ },
+ });
+ const start = rpc.requests.find((entry) => entry.method === "thread/start");
+ assert.equal(start.params.cwd, "/tmp/default");
+ const update = rpc.requests.find((entry) => entry.method === "thread/settings/update");
+ assert.deepEqual(update.params, {
+ threadId: "thread-new",
+ model: "gpt-5.6-terra",
+ effort: "high",
+ approvalPolicy: "on-request",
+ approvalsReviewer: "user",
+ permissions: ":workspace",
+ });
+ assert.equal(Object.prototype.hasOwnProperty.call(update.params, "sandboxPolicy"), false);
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.metadata.model, "gpt-5.6-terra");
+ assert.equal(snapshot.metadata.latestReasoningEffort, "high");
+ assert.equal(snapshot.metadata.sandboxPolicy, "workspace-write");
+ await adapter.dispose();
+});
+
+test("thread settings updates require the send_task_input capability", async () => {
+ const rpc = new FakeRpc();
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ await adapter.start();
+ const relay = new FakeRelay();
+ const host = new RelayHost({
+ adapter,
+ relay,
+ capabilities: ["read_output"],
+ sessionId: "test-session",
+ });
+
+ await host.handleFrame({
+ kind: "command",
+ type: "thread.settings.update",
+ commandId: "settings-without-capability",
+ actor: { role: "operator" },
+ payload: { threadSettings: { model: "gpt-5.6-sol", effort: "high" } },
+ });
+
+ const result = relay.frames.find((frame) => frame.payload?.commandId === "settings-without-capability");
+ assert.equal(result.type, "command.rejected");
+ assert.match(result.payload.error, /missing capability: send_task_input/);
+ await adapter.dispose();
+});
+
+test("RelayHost exposes session list as read-only and protects session selection", async () => {
+ const relay = new FakeRelay();
+ const calls = [];
+ const adapter = {
+ async start() {},
+ async sendInput() { return {}; },
+ async cancel() { return {}; },
+ async respondApproval() { return {}; },
+ async snapshot() { return { threadId: "thread-a", turnId: null, state: "idle", pendingApprovals: [], outputTail: "" }; },
+ onEvent() { return { dispose() {} }; },
+ async dispose() {},
+ async listSessions(params) {
+ calls.push({ method: "listSessions", params });
+ return { sessions: [{ threadId: "thread-a", title: "A", updatedAtMs: null, active: true, available: true }], activeThreadId: "thread-a" };
+ },
+ async selectSession(params) {
+ calls.push({ method: "selectSession", params });
+ return { threadId: params.threadId, previousThreadId: "thread-a", switched: true, available: true };
+ },
+ async newSession(params) {
+ calls.push({ method: "newSession", params });
+ return { opened: true, command: "chatgpt.newCodexPanel" };
+ },
+ getControlMode() { return "sync"; },
+ async setControlMode(params) {
+ calls.push({ method: "setControlMode", params });
+ return { changed: true, controlMode: params.mode, previousControlMode: "sync", modeEpoch: 1 };
+ },
+ };
+ const host = new RelayHost({
+ adapter,
+ relay,
+ capabilities: ["read_output", "send_task_input"],
+ sessionId: "test-session",
+ });
+
+ await host.handleFrame({ kind: "command", type: "session/list", commandId: "list-1", actor: { role: "viewer" }, payload: {} });
+ const listed = relay.frames.find((frame) => frame.payload?.commandId === "list-1");
+ assert.equal(listed.type, "command.accepted");
+ assert.equal(listed.payload.result.activeThreadId, "thread-a");
+ assert.equal(calls[0].method, "listSessions");
+
+ await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-viewer", actor: { role: "viewer" }, payload: { threadId: "thread-b" } });
+ const denied = relay.frames.find((frame) => frame.payload?.commandId === "select-viewer");
+ assert.equal(denied.type, "command.rejected");
+
+ await host.handleFrame({ kind: "command", type: "session/select", commandId: "select-operator", actor: { role: "operator" }, payload: { threadId: "thread-b" } });
+ const selected = relay.frames.find((frame) => frame.payload?.commandId === "select-operator");
+ assert.equal(selected.type, "command.accepted");
+ assert.equal(selected.payload.result.threadId, "thread-b");
+ assert.equal(calls.at(-1).method, "selectSession");
+
+ await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-viewer", actor: { role: "viewer" }, payload: {} });
+ const deniedNew = relay.frames.find((frame) => frame.payload?.commandId === "new-viewer");
+ assert.equal(deniedNew.type, "command.rejected");
+
+ await host.handleFrame({ kind: "command", type: "session/new", commandId: "new-operator", actor: { role: "operator" }, payload: {} });
+ const opened = relay.frames.find((frame) => frame.payload?.commandId === "new-operator");
+ assert.equal(opened.type, "command.accepted");
+ assert.equal(opened.payload.result.command, "chatgpt.newCodexPanel");
+ assert.equal(calls.at(-1).method, "newSession");
+
+ await host.handleFrame({ kind: "command", type: "control/mode/get", commandId: "mode-get-viewer", actor: { role: "viewer" }, payload: {} });
+ const mode = relay.frames.find((frame) => frame.payload?.commandId === "mode-get-viewer");
+ assert.equal(mode.type, "command.accepted");
+ assert.equal(mode.payload.result.mode, "sync");
+
+ await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-viewer", actor: { role: "viewer" }, payload: { mode: "async" } });
+ const deniedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-viewer");
+ assert.equal(deniedMode.type, "command.rejected");
+
+ await host.handleFrame({ kind: "command", type: "control/mode/set", commandId: "mode-set-operator", actor: { role: "operator" }, payload: { mode: "async" } });
+ const changedMode = relay.frames.find((frame) => frame.payload?.commandId === "mode-set-operator");
+ assert.equal(changedMode.type, "command.accepted");
+ assert.equal(changedMode.payload.result.controlMode, "async");
+ assert.equal(calls.at(-1).method, "setControlMode");
+});
+
+test("approval decision conflicts and unknown tagged objects fail closed", async () => {
+ const rpc = new FakeRpc();
+ const adapter = new CodexAgentAdapter({ approvalTimeoutMs: 0 }, rpc);
+ await adapter.start();
+ const relay = new FakeRelay();
+ const host = new RelayHost({
+ adapter,
+ relay,
+ capabilities: ["read_output", "send_task_input", "cancel_task", "approve_low_risk"],
+ sessionId: "test-session",
+ });
+
+ rpc.emitRequest({
+ id: 3,
+ method: "execCommandApproval",
+ params: { conversationId: "thread-test", callId: "call-3", command: ["echo", "safe"] },
+ });
+ await host.handleFrame({
+ kind: "command",
+ type: "approval.respond",
+ commandId: "conflicting-response",
+ actor: { role: "operator" },
+ payload: {
+ requestId: 3,
+ decision: "deny",
+ response: { decision: "approved_mcp_policy_amendment" },
+ },
+ });
+ assert.deepEqual(rpc.responses[0], {
+ id: 3,
+ result: { decision: { denied: { rejection: "approval response implies allow, but decision is deny" } } },
+ });
+
+ rpc.emitRequest({
+ id: 4,
+ method: "item/commandExecution/requestApproval",
+ params: { threadId: "thread-test", turnId: "turn-test", itemId: "item-4", command: "echo safe" },
+ });
+ await host.handleFrame({
+ kind: "command",
+ type: "approval.respond",
+ commandId: "unknown-tagged-response",
+ actor: { role: "operator" },
+ payload: {
+ requestId: 4,
+ decision: "allow",
+ response: { decision: { futurePolicyGrant: { scope: "all" } } },
+ },
+ });
+ assert.deepEqual(rpc.responses[1], {
+ id: 4,
+ result: { decision: "decline" },
+ });
+ await adapter.dispose();
+});
diff --git a/aether-vscodex/test/cloud-server.test.js b/aether-vscodex/test/cloud-server.test.js
new file mode 100644
index 000000000..7940c3bea
--- /dev/null
+++ b/aether-vscodex/test/cloud-server.test.js
@@ -0,0 +1,298 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+const { WebSocket } = require("ws");
+
+const { AetherVscodexCloudServer, RoomManager } = require("../cloud/server.js");
+
+const internalToken = "test-internal-token-with-enough-entropy";
+
+function internalFetch(base, pathname, options = {}) {
+ return fetch(`${base}${pathname}`, {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${internalToken}`,
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
+ ...(options.headers || {}),
+ },
+ });
+}
+
+function websocketClient(base, clientType, token, sessionId) {
+ const socket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
+ const messages = [];
+ const waiters = [];
+ const wait = (predicate, timeout = 5_000, label = "websocket frame") => new Promise((resolve, reject) => {
+ const existing = messages.find(predicate);
+ if (existing) return resolve(existing);
+ const timer = setTimeout(() => {
+ const index = waiters.findIndex((entry) => entry.resolve === resolve);
+ if (index >= 0) waiters.splice(index, 1);
+ reject(new Error(`timed out waiting for ${label}; received: ${JSON.stringify(messages.map((message) => ({ type: message.type, kind: message.kind, commandId: message.commandId })))}`));
+ }, timeout);
+ waiters.push({
+ predicate,
+ resolve: (message) => {
+ clearTimeout(timer);
+ resolve(message);
+ },
+ });
+ });
+ socket.on("message", (data) => {
+ const message = JSON.parse(data.toString("utf8"));
+ messages.push(message);
+ for (let index = waiters.length - 1; index >= 0; index -= 1) {
+ if (!waiters[index].predicate(message)) continue;
+ const waiter = waiters.splice(index, 1)[0];
+ waiter.resolve(message);
+ }
+ });
+ return new Promise((resolve, reject) => {
+ socket.once("open", () => {
+ socket.send(JSON.stringify({ v: 1, kind: "hello", clientType, protocol: 1, ...(sessionId ? { sessionId } : {}) }));
+ socket.send(JSON.stringify(clientType === "host"
+ ? { v: 1, kind: "auth", accessToken: token }
+ : { type: "auth", token }));
+ wait((message) => message.type === "auth.ok").then(() => resolve({ socket, wait, messages }), reject);
+ });
+ socket.once("error", reject);
+ });
+}
+
+async function pairDevice(base, userId, name) {
+ const pairingResponse = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/pairings`, {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ });
+ assert.equal(pairingResponse.status, 201);
+ const pairing = await pairingResponse.json();
+ const exchangeResponse = await fetch(`${base}/v1/pairings/exchange`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ code: pairing.code, name }),
+ });
+ assert.equal(exchangeResponse.status, 201);
+ return exchangeResponse.json();
+}
+
+async function browserTicket(base, userId, deviceId) {
+ const response = await internalFetch(base, `/internal/v1/users/${encodeURIComponent(userId)}/ws-tickets`, {
+ method: "POST",
+ body: JSON.stringify({ device_id: deviceId }),
+ });
+ assert.equal(response.status, 201);
+ return response.json();
+}
+
+function exchangeAttempt(base, headers = {}) {
+ return fetch(`${base}/v1/pairings/exchange`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify({ code: "INVALID-CODE" }),
+ });
+}
+
+test("pairing exchange trusts a gateway client IP only with valid internal authentication", async (t) => {
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-rate-limit-"));
+ const server = new AetherVscodexCloudServer({
+ host: "127.0.0.1",
+ port: 0,
+ internalToken,
+ publicWsUrl: "wss://aether.example/api/vscodex/ws",
+ dataDir,
+ });
+ await server.start();
+ t.after(async () => {
+ await server.stop();
+ fs.rmSync(dataDir, { recursive: true, force: true });
+ });
+ const address = server.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const trustedHeaders = (clientIp) => ({
+ Authorization: `Bearer ${internalToken}`,
+ "X-Aether-Client-IP": clientIp,
+ });
+
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 400);
+ }
+ assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.10"))).status, 429);
+ assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.11"))).status, 400);
+ assert.equal((await exchangeAttempt(base, trustedHeaders("2001:db8::10"))).status, 400);
+
+ server.exchangeAttempts.clear();
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": `198.51.100.${20 + attempt}` })).status, 400);
+ }
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ assert.equal((await exchangeAttempt(base, {
+ Authorization: "Bearer invalid-internal-token",
+ "X-Aether-Client-IP": `198.51.100.${30 + attempt}`,
+ })).status, 400);
+ }
+ assert.equal((await exchangeAttempt(base, { "X-Aether-Client-IP": "198.51.100.99" })).status, 429);
+
+ server.exchangeAttempts.clear();
+ const invalidForwardedAddresses = ["proxy.internal", "198.51.100.40, 198.51.100.41"];
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ assert.equal((await exchangeAttempt(base, trustedHeaders(invalidForwardedAddresses[attempt % 2]))).status, 400);
+ }
+ assert.equal((await exchangeAttempt(base, trustedHeaders("198.51.100.42, 198.51.100.43"))).status, 429);
+});
+
+test("cloud sidecar pairs a device and isolates host/browser traffic by Aether user and device", async (t) => {
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-test-"));
+ const server = new AetherVscodexCloudServer({
+ host: "127.0.0.1",
+ port: 0,
+ internalToken,
+ publicWsUrl: "wss://aether.example/api/vscodex/ws",
+ dataDir,
+ pairingTtlMs: 5_000,
+ ticketTtlMs: 5_000,
+ });
+ await server.start();
+ t.after(async () => {
+ await server.stop();
+ fs.rmSync(dataDir, { recursive: true, force: true });
+ });
+ const address = server.address();
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const unauthorized = await fetch(`${base}/internal/v1/users/user-a/devices`);
+ assert.equal(unauthorized.status, 401);
+
+ const paired = await pairDevice(base, "user-a", "MacBook VS Code");
+ assert.match(paired.device_token, /^avx1\./);
+ const devicesResponse = await internalFetch(base, "/internal/v1/users/user-a/devices");
+ assert.equal(devicesResponse.status, 200);
+ const devices = await devicesResponse.json();
+ assert.deepEqual(devices.devices.map((device) => ({ id: device.id, name: device.name, connected: device.connected })), [
+ { id: paired.device_id, name: "MacBook VS Code", connected: false },
+ ]);
+
+ const ticket = await browserTicket(base, "user-a", paired.device_id);
+ assert.equal(ticket.ws_url, "/api/vscodex/ws");
+ const host = await websocketClient(base, "host", paired.device_token, "host-user-a");
+ const browser = await websocketClient(base, "web", ticket.ticket);
+ t.after(() => host.socket.close());
+ t.after(() => browser.socket.close());
+ browser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
+
+ host.socket.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "connection.opened",
+ id: "connection-a",
+ sessionId: "host-user-a",
+ seq: 1,
+ ts: new Date().toISOString(),
+ payload: {},
+ }));
+ host.socket.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "session.snapshot",
+ id: "snapshot-a",
+ sessionId: "host-user-a",
+ seq: 2,
+ ts: new Date().toISOString(),
+ payload: { threadId: "thread-a", state: "idle", messages: [{ kind: "assistant", text: "user-a-only" }] },
+ }));
+ const snapshot = await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot", 5_000, "session snapshot");
+ assert.equal(snapshot.payload.threadId, "thread-a");
+ assert.equal(snapshot.payload.messages[0].text, "user-a-only");
+
+ browser.socket.send(JSON.stringify({ type: "command", commandId: "cmd-a", method: "session/list", params: {} }));
+ const command = await host.wait((message) => message.kind === "command" && message.commandId === "cmd-a", 5_000, "browser command");
+ assert.equal(command.type, "session/list");
+
+ const secondUser = await pairDevice(base, "user-b", "Other VS Code");
+ const secondTicket = await browserTicket(base, "user-b", secondUser.device_id);
+ const secondBrowser = await websocketClient(base, "web", secondTicket.ticket);
+ t.after(() => secondBrowser.socket.close());
+ secondBrowser.socket.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ assert.equal(secondBrowser.messages.some((message) => message.payload?.threadId === "thread-a"), false);
+
+ const reusedTicket = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
+ const closed = new Promise((resolve, reject) => {
+ reusedTicket.once("open", () => {
+ reusedTicket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 }));
+ reusedTicket.send(JSON.stringify({ type: "auth", token: ticket.ticket }));
+ });
+ reusedTicket.once("close", (code) => resolve(code));
+ reusedTicket.once("error", reject);
+ });
+ assert.equal(await closed, 1008, "browser tickets are one-time credentials");
+});
+
+test("device revocation closes its room and blocks future host authentication", async (t) => {
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aether-vscodex-revoke-"));
+ const server = new AetherVscodexCloudServer({
+ host: "127.0.0.1",
+ port: 0,
+ internalToken,
+ publicWsUrl: "wss://aether.example/api/vscodex/ws",
+ dataDir,
+ });
+ await server.start();
+ t.after(async () => {
+ await server.stop();
+ fs.rmSync(dataDir, { recursive: true, force: true });
+ });
+ const address = server.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const paired = await pairDevice(base, "user-a", "Revoked device");
+ const host = await websocketClient(base, "host", paired.device_token, "revoked-host");
+
+ const response = await internalFetch(base, `/internal/v1/users/user-a/devices/${paired.device_id}`, { method: "DELETE" });
+ assert.equal(response.status, 204);
+ await new Promise((resolve) => host.socket.once("close", resolve));
+
+ const rejected = new WebSocket(`${base.replace(/^http/, "ws")}/api/vscodex/ws`);
+ const closed = new Promise((resolve, reject) => {
+ rejected.once("open", () => {
+ rejected.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId: "retry" }));
+ rejected.send(JSON.stringify({ v: 1, kind: "auth", accessToken: paired.device_token }));
+ });
+ rejected.once("close", (code) => resolve(code));
+ rejected.once("error", reject);
+ });
+ assert.equal(await closed, 1008);
+});
+
+test("room revocation wins a concurrent room creation", async () => {
+ const rooms = new RoomManager();
+ let releaseCreation;
+ const creationGate = new Promise((resolve) => { releaseCreation = resolve; });
+ let stopped = false;
+ const room = {
+ key: rooms.key("user-a", "device-a"),
+ userId: "user-a",
+ deviceId: "device-a",
+ relay: { stop: async () => { stopped = true; } },
+ connections: 0,
+ lastActiveMs: Date.now(),
+ };
+ rooms.createRoom = async (key) => {
+ await creationGate;
+ rooms.rooms.set(key, room);
+ return room;
+ };
+
+ const pendingGet = rooms.get("user-a", "device-a");
+ await new Promise((resolve) => setImmediate(resolve));
+ const pendingRevoke = rooms.revoke("user-a", "device-a");
+ releaseCreation();
+
+ await assert.rejects(pendingGet, /device revoked/);
+ await pendingRevoke;
+ assert.equal(stopped, true);
+ assert.equal(rooms.rooms.has(room.key), false);
+ await assert.rejects(rooms.get("user-a", "device-a"), /device revoked/);
+});
diff --git a/aether-vscodex/test/codex-ipc-adapter.test.js b/aether-vscodex/test/codex-ipc-adapter.test.js
new file mode 100644
index 000000000..6f19ffc66
--- /dev/null
+++ b/aether-vscodex/test/codex-ipc-adapter.test.js
@@ -0,0 +1,2247 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+
+const { CodexIpcAgentAdapter } = require("../vscode-extension/dist/codexIpcAgentAdapter.js");
+
+const THREAD_ID = "11111111-1111-4111-8111-111111111111";
+const SECOND_THREAD_ID = "22222222-2222-4222-8222-222222222222";
+const STALE_THREAD_ID = "33333333-3333-4333-8333-333333333333";
+const THIRD_THREAD_ID = "44444444-4444-4444-8444-444444444444";
+const ULID_THREAD_ID = "01a0399e790373d63b6fcde4e1f97d02";
+
+async function waitFor(predicate, timeoutMs = 1_000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await predicate()) return;
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ assert.fail(`condition was not met within ${timeoutMs}ms`);
+}
+
+class FakeIpcClient {
+ constructor(state, emitSnapshot = true) {
+ this.socketPath = "/tmp/fake-codex-ipc.sock";
+ this.state = state;
+ this.emitSnapshot = emitSnapshot;
+ this.broadcastListeners = new Set();
+ this.streamListeners = new Set();
+ this.errorListeners = new Set();
+ this.closeListeners = new Set();
+ this.calls = [];
+ this.streamStates = new Map();
+ }
+
+ subscribe(set, listener) {
+ set.add(listener);
+ return { dispose: () => set.delete(listener) };
+ }
+
+ onBroadcast(listener) { return this.subscribe(this.broadcastListeners, listener); }
+ onStreamEvent(listener) { return this.subscribe(this.streamListeners, listener); }
+ onError(listener) { return this.subscribe(this.errorListeners, listener); }
+ onClose(listener) { return this.subscribe(this.closeListeners, listener); }
+ getClientId() { return "follower"; }
+ getConversationState(threadId) {
+ const state = this.streamStates.get(threadId);
+ if (!state) return undefined;
+ return {
+ ...state,
+ conversationState: JSON.parse(JSON.stringify(state.conversationState)),
+ };
+ }
+ async connect() { this.calls.push({ method: "connect" }); return "follower"; }
+ async findThreadOwner(threadId) {
+ this.calls.push({ method: "findThreadOwner", threadId });
+ return threadId === THREAD_ID ? "owner" : null;
+ }
+ async followConversation(threadId, following, options) {
+ this.calls.push({ method: "followConversation", threadId, following, options });
+ if (!following) {
+ this.streamStates.delete(threadId);
+ return;
+ }
+ if (this.emitSnapshot) queueMicrotask(() => this.emitState(this.state, 1, "snapshot", threadId));
+ }
+ emitState(state, revision, kind = "snapshot", threadId = THREAD_ID, ownerClientId = "owner") {
+ const event = {
+ kind,
+ conversationId: threadId,
+ hostId: "local",
+ ownerClientId,
+ revision,
+ conversationState: state,
+ raw: { type: "broadcast", method: "thread-stream-state-changed", version: 11 },
+ };
+ this.streamStates.set(threadId, {
+ conversationId: threadId,
+ hostId: "local",
+ ownerClientId,
+ revision,
+ conversationState: JSON.parse(JSON.stringify(state)),
+ });
+ for (const listener of this.streamListeners) listener(event);
+ }
+ emitFollowing(threadId, following, options = {}) {
+ const frame = {
+ type: "broadcast",
+ method: "thread-stream-following-changed",
+ version: options.version ?? 1,
+ sourceClientId: options.sourceClientId ?? "owner",
+ ...(options.targetClientIds ? { targetClientIds: options.targetClientIds } : {}),
+ params: {
+ conversationId: threadId,
+ hostId: options.hostId ?? "local",
+ following,
+ },
+ };
+ for (const listener of this.broadcastListeners) listener(frame);
+ }
+ emitClientStatus(clientId, status, options = {}) {
+ const frame = {
+ type: "broadcast",
+ method: "client-status-changed",
+ version: options.version ?? 0,
+ sourceClientId: options.sourceClientId ?? clientId,
+ params: {
+ clientId,
+ clientType: options.clientType ?? "vscode-webview",
+ status,
+ },
+ };
+ for (const listener of this.broadcastListeners) listener(frame);
+ }
+ emitClose(error = new Error("fixture IPC socket closed")) {
+ for (const listener of this.closeListeners) listener(error);
+ }
+ async startTurn(threadId, input, options) {
+ this.calls.push({ method: "startTurn", threadId, input, options });
+ return { turnId: "turn-new" };
+ }
+ async steerTurn(threadId, input, options) {
+ this.calls.push({ method: "steerTurn", threadId, input, options });
+ return { turnId: "turn-new" };
+ }
+ async updateThreadSettings(threadId, settings, options) {
+ this.calls.push({ method: "updateThreadSettings", threadId, settings, options });
+ return { updated: true };
+ }
+ async interruptTurn(threadId, options) {
+ this.calls.push({ method: "interruptTurn", threadId, options });
+ return { interrupted: true };
+ }
+ async respondCommandApproval(threadId, requestId, decision, options) {
+ this.calls.push({ method: "respondCommandApproval", threadId, requestId, decision, options });
+ return { accepted: true };
+ }
+ async respondFileApproval(threadId, requestId, decision, options) {
+ this.calls.push({ method: "respondFileApproval", threadId, requestId, decision, options });
+ return { accepted: true };
+ }
+ async respondPermissionsApproval(threadId, requestId, response, options) {
+ this.calls.push({ method: "respondPermissionsApproval", threadId, requestId, response, options });
+ return { accepted: true };
+ }
+ async respondUserInput(threadId, requestId, response, options) {
+ this.calls.push({ method: "respondUserInput", threadId, requestId, response, options });
+ return { accepted: true };
+ }
+ async respondMcpElicitation(threadId, requestId, response, options) {
+ this.calls.push({ method: "respondMcpElicitation", threadId, requestId, response, options });
+ return { accepted: true };
+ }
+ async loadCompleteHistory() { this.calls.push({ method: "loadCompleteHistory" }); }
+ async dispose() { this.calls.push({ method: "dispose" }); }
+}
+
+class DiscoveryIpcClient extends FakeIpcClient {
+ async findThreadOwner(threadId) {
+ this.calls.push({ method: "findThreadOwner", threadId });
+ return "owner";
+ }
+}
+
+class MultiSessionIpcClient extends FakeIpcClient {
+ constructor(states, owners = {}) {
+ super(states.get(THREAD_ID));
+ this.states = states;
+ this.owners = owners;
+ }
+
+ async findThreadOwner(threadId) {
+ this.calls.push({ method: "findThreadOwner", threadId });
+ return this.owners[threadId] || null;
+ }
+
+ async followConversation(threadId, following, options) {
+ this.calls.push({ method: "followConversation", threadId, following, options });
+ if (!following) {
+ this.streamStates.delete(threadId);
+ return;
+ }
+ if (this.states.has(threadId)) {
+ const owner = this.owners[threadId] || "owner";
+ queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner));
+ }
+ }
+}
+
+/** Simulates a target owned by another Codex process that never answers follow. */
+class NoSnapshotSwitchIpcClient extends MultiSessionIpcClient {
+ constructor(states, owners = {}) {
+ super(states, owners);
+ this.targetFollowFailed = false;
+ }
+
+ async followConversation(threadId, following, options) {
+ this.calls.push({ method: "followConversation", threadId, following, options });
+ if (!following) {
+ this.streamStates.delete(threadId);
+ return;
+ }
+ if (threadId === SECOND_THREAD_ID) {
+ this.targetFollowFailed = true;
+ return;
+ }
+ // The old owner is deliberately silent after the failed target attach;
+ // the adapter must restore from its cached stream state instead.
+ if (this.targetFollowFailed && threadId === THREAD_ID) return;
+ if (this.states.has(threadId)) {
+ const owner = this.owners[threadId] || "owner";
+ queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner));
+ }
+ }
+}
+
+class ThrowingTargetFollowIpcClient extends MultiSessionIpcClient {
+ async followConversation(threadId, following, options) {
+ if (threadId === SECOND_THREAD_ID && following) {
+ this.calls.push({ method: "followConversation", threadId, following, options });
+ throw new Error("target follow failed immediately");
+ }
+ return super.followConversation(threadId, following, options);
+ }
+}
+
+class OwnerChangingIpcClient extends MultiSessionIpcClient {
+ constructor(states, owners = {}) {
+ super(states, owners);
+ this.targetDiscoveryCount = 0;
+ }
+
+ async findThreadOwner(threadId) {
+ this.calls.push({ method: "findThreadOwner", threadId });
+ if (threadId === SECOND_THREAD_ID) {
+ this.targetDiscoveryCount += 1;
+ return this.targetDiscoveryCount === 1 ? "owner-b" : "owner-c";
+ }
+ return this.owners[threadId] || null;
+ }
+}
+
+/** Delays the first target snapshot so list probing overlaps a session selection request. */
+class DelayedProbeIpcClient extends MultiSessionIpcClient {
+ constructor(states, owners = {}) {
+ super(states, owners);
+ this.targetFollowCount = 0;
+ }
+
+ async followConversation(threadId, following, options) {
+ this.calls.push({ method: "followConversation", threadId, following, options });
+ if (!following) {
+ this.streamStates.delete(threadId);
+ return;
+ }
+ if (!this.states.has(threadId)) return;
+ const owner = this.owners[threadId] || "owner";
+ if (threadId === SECOND_THREAD_ID) {
+ this.targetFollowCount += 1;
+ const delay = this.targetFollowCount === 1 ? 30 : 0;
+ setTimeout(() => this.emitState(this.states.get(threadId), this.targetFollowCount, "snapshot", threadId, owner), delay);
+ return;
+ }
+ queueMicrotask(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner));
+ }
+}
+
+/** Delays the first waiting attach so a newer official route can supersede it. */
+class WaitingRouteRaceIpcClient extends MultiSessionIpcClient {
+ async followConversation(threadId, following, options) {
+ this.calls.push({ method: "followConversation", threadId, following, options });
+ if (!following) {
+ this.streamStates.delete(threadId);
+ return;
+ }
+ if (!this.states.has(threadId)) return;
+ const owner = this.owners[threadId] || "owner";
+ const delay = threadId === THREAD_ID ? 30 : 0;
+ setTimeout(() => this.emitState(this.states.get(threadId), 1, "snapshot", threadId, owner), delay);
+ }
+}
+
+/** Holds fallback discovery so an official route can arrive while it is stale. */
+class WaitingPollRouteRaceIpcClient extends WaitingRouteRaceIpcClient {
+ constructor(states, owners = {}) {
+ super(states, owners);
+ this.delayFallbackDiscovery = false;
+ this.fallbackDiscoveryStarted = false;
+ this.fallbackOwner = new Promise((resolve) => {
+ this.resolveFallbackOwner = resolve;
+ });
+ }
+
+ async findThreadOwner(threadId) {
+ this.calls.push({ method: "findThreadOwner", threadId });
+ if (threadId === SECOND_THREAD_ID && this.delayFallbackDiscovery) {
+ this.fallbackDiscoveryStarted = true;
+ return this.fallbackOwner;
+ }
+ return this.owners[threadId] || null;
+ }
+
+ releaseFallbackDiscovery() {
+ this.delayFallbackDiscovery = false;
+ const resolve = this.resolveFallbackOwner;
+ this.resolveFallbackOwner = null;
+ if (resolve) resolve(this.owners[SECOND_THREAD_ID] || null);
+ }
+}
+
+/** Holds the post-snapshot owner confirmation so assertions run mid-switch. */
+class DelayedTargetOwnerConfirmationIpcClient extends MultiSessionIpcClient {
+ constructor(states, owners = {}) {
+ super(states, owners);
+ this.targetDiscoveryCount = 0;
+ this.targetConfirmationStarted = false;
+ this.targetOwnerConfirmation = new Promise((resolve) => {
+ this.resolveTargetOwnerConfirmation = resolve;
+ });
+ }
+
+ async findThreadOwner(threadId) {
+ this.calls.push({ method: "findThreadOwner", threadId });
+ const owner = this.owners[threadId] || null;
+ if (threadId !== SECOND_THREAD_ID) return owner;
+ this.targetDiscoveryCount += 1;
+ if (this.targetDiscoveryCount === 1) return owner;
+ this.targetConfirmationStarted = true;
+ return this.targetOwnerConfirmation;
+ }
+
+ releaseTargetOwnerConfirmation() {
+ if (!this.resolveTargetOwnerConfirmation) return;
+ const resolve = this.resolveTargetOwnerConfirmation;
+ this.resolveTargetOwnerConfirmation = null;
+ resolve(this.owners[SECOND_THREAD_ID] || null);
+ }
+}
+
+function fixtureState(requests = []) {
+ return {
+ id: THREAD_ID,
+ title: "fixture session",
+ cwd: "/tmp/workspace",
+ turns: [{
+ id: "turn-old",
+ status: "completed",
+ items: [
+ { type: "userMessage", id: "user-1", content: [{ type: "text", text: "hello" }] },
+ { type: "agentMessage", id: "agent-1", text: "hi from VS Code" },
+ ],
+ }],
+ requests,
+ threadRuntimeStatus: { type: "idle" },
+ };
+}
+
+test("IPC adapter follows an existing session and routes input/approval without spawning", async () => {
+ const client = new FakeIpcClient(fixtureState([
+ {
+ id: 7,
+ method: "item/commandExecution/requestApproval",
+ params: { threadId: THREAD_ID, turnId: "turn-old", command: "echo safe" },
+ },
+ {
+ id: "question-1",
+ method: "item/tool/requestUserInput",
+ params: { threadId: THREAD_ID, turnId: "turn-old", questions: [{ id: "choice", question: "Pick one" }] },
+ },
+ ]));
+ const events = [];
+ let newSessionCalls = 0;
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ openNewSession: async () => {
+ newSessionCalls += 1;
+ return { opened: true, command: "chatgpt.newCodexPanel" };
+ },
+ });
+ adapter.onEvent((event) => events.push(event));
+
+ await adapter.start();
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.threadId, THREAD_ID);
+ assert.equal(snapshot.metadata.adapter, "codex-ipc-follower");
+ assert.match(snapshot.outputTail, /hi from VS Code/);
+ assert.deepEqual(snapshot.pendingApprovals.map((item) => item.requestId), [7]);
+ assert.equal(snapshot.pendingRequests.length, 2);
+ assert.equal(client.calls.some((call) => call.method === "startProcess"), false);
+ await assert.rejects(() => adapter.startThread({}), /does not create a new thread/);
+ assert.deepEqual(await adapter.newSession(), { opened: true, command: "chatgpt.newCodexPanel" });
+ assert.equal(newSessionCalls, 1);
+
+ await adapter.startTurn({ text: "remote input" });
+ const startCall = client.calls.find((call) => call.method === "startTurn");
+ assert.deepEqual(startCall.input, "remote input");
+ assert.equal(startCall.options.ownerClientId, "owner");
+
+ await adapter.respondApproval(7, "allow");
+ const approvalCall = client.calls.find((call) => call.method === "respondCommandApproval");
+ assert.equal(approvalCall.decision, "accept");
+
+ await adapter.respondApproval("question-1", "allow", undefined, { answers: { choice: ["yes"] } });
+ const inputCall = client.calls.find((call) => call.method === "respondUserInput");
+ assert.deepEqual(inputCall.response, { answers: { choice: { answers: ["yes"] } } });
+ const outputSnapshot = events.find((event) => event.type === "output.snapshot");
+ assert.ok(outputSnapshot);
+ assert.deepEqual(outputSnapshot.payload.messages.map((item) => [item.role, item.kind, item.text]), [
+ ["user", "user", "hello"],
+ ["assistant", "assistant", "hi from VS Code"],
+ ]);
+ await adapter.dispose();
+});
+
+test("IPC adapter clears stale projections on close while preserving closed session identity", async () => {
+ const client = new FakeIpcClient(fixtureState());
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+
+ const before = await adapter.snapshot();
+ assert.equal(before.threadId, THREAD_ID);
+ assert.equal(before.metadata.ownerClientId, "owner");
+ assert.equal(before.metadata.attachReady, true);
+ assert.ok(before.messages.length > 0);
+ assert.match(before.outputTail, /hi from VS Code/);
+ events.length = 0;
+
+ client.emitClose(new Error("fixture IPC connection lost"));
+
+ const closedEvent = events.find((event) => event.type === "connection.closed");
+ assert.ok(closedEvent);
+ assert.equal(closedEvent.threadId, THREAD_ID);
+ assert.equal(closedEvent.payload.ownerClientId, "owner");
+ assert.equal(closedEvent.payload.message, "fixture IPC connection lost");
+
+ const after = await adapter.snapshot();
+ assert.equal(after.state, "disconnected");
+ assert.equal(after.threadId, null);
+ assert.equal(after.turnId, null);
+ assert.equal(after.outputTail, "");
+ assert.deepEqual(after.messages, []);
+ assert.deepEqual(after.subagents, []);
+ assert.equal(after.metadata.attachReady, false);
+ assert.equal(Object.hasOwn(after.metadata, "ownerClientId"), false);
+ assert.equal(Object.hasOwn(after.metadata, "revision"), false);
+ await adapter.dispose();
+});
+
+test("IPC adapter persists model and effort through the official follower settings envelope", async () => {
+ const client = new FakeIpcClient(fixtureState());
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+
+ await adapter.start();
+ const result = await adapter.updateThreadSettings({
+ threadSettings: { model: " gpt-5.6-sol ", effort: " ultra " },
+ // UI-only fields must not leak into the owner request.
+ commandId: "ignored",
+ });
+ assert.deepEqual(result, { updated: true });
+ const call = client.calls.find((entry) => entry.method === "updateThreadSettings");
+ assert.equal(call.threadId, THREAD_ID);
+ assert.deepEqual(call.settings, { model: "gpt-5.6-sol", effort: "ultra" });
+ assert.equal(call.options.ownerClientId, "owner");
+ await assert.rejects(() => adapter.updateThreadSettings({ model: "" }), /non-empty string/);
+ await adapter.dispose();
+});
+
+test("IPC adapter projects official latest model and reasoning effort fields", async () => {
+ const state = fixtureState();
+ state.latestModel = "gpt-5.6-sol";
+ state.latestReasoningEffort = "ultra";
+ state.latestThreadSettings = {
+ model: "gpt-5.6-sol",
+ modelProvider: "aether",
+ effort: "ultra",
+ multiAgentMode: "explicitRequestOnly",
+ };
+ const client = new FakeIpcClient(state);
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ const metadata = (await adapter.snapshot()).metadata;
+ assert.equal(metadata.model, "gpt-5.6-sol");
+ assert.equal(metadata.latestModel, "gpt-5.6-sol");
+ assert.equal(metadata.effort, "ultra");
+ assert.equal(metadata.latestReasoningEffort, "ultra");
+ assert.equal(metadata.modelProvider, "aether");
+ await adapter.dispose();
+});
+
+test("IPC adapter projects a bounded model catalog from compatible state locations", async () => {
+ const state = fixtureState();
+ // Exercise all of the state names used by different official extension
+ // builds. The same entries should be merged rather than duplicated.
+ state.availableModels = [{ model: "gpt-5.6-sol" }];
+ state.models = [
+ { model: "gpt-5.6-sol", description: "initial description" },
+ { id: "gpt-5.6-terra", displayName: "5.6 Terra", efforts: ["low", "medium"] },
+ ];
+ state.modelCatalog = {
+ data: [{
+ id: "gpt-5.6-sol",
+ model: "gpt-5.6-sol",
+ displayName: "5.6 Sol",
+ description: "通用 Codex 模型",
+ hidden: false,
+ isDefault: true,
+ defaultReasoningEffort: "medium",
+ supportedReasoningEfforts: [
+ { reasoningEffort: "low", description: "快速" },
+ { reasoningEffort: "high", description: "深入" },
+ ],
+ apiKey: "sk-this-must-not-cross-the-relay",
+ capabilities: { internal: true },
+ }],
+ nextCursor: "private-cursor",
+ };
+ state.listModels = { data: [{ model: "gpt-5.6-terra", hidden: false }] };
+
+ const client = new FakeIpcClient(state);
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+
+ const metadata = (await adapter.snapshot()).metadata;
+ assert.ok(Array.isArray(metadata.availableModels));
+ assert.deepEqual(metadata.models, metadata.availableModels);
+ assert.equal(metadata.availableModels.length, 2);
+ const sol = metadata.availableModels.find((entry) => entry.model === "gpt-5.6-sol");
+ assert.deepEqual(sol, {
+ model: "gpt-5.6-sol",
+ id: "gpt-5.6-sol",
+ description: "initial description",
+ displayName: "5.6 Sol",
+ hidden: false,
+ isDefault: true,
+ defaultReasoningEffort: "medium",
+ supportedReasoningEfforts: [
+ { reasoningEffort: "low", description: "快速" },
+ { reasoningEffort: "high", description: "深入" },
+ ],
+ });
+ const terra = metadata.availableModels.find((entry) => entry.model === "gpt-5.6-terra");
+ assert.deepEqual(terra, {
+ model: "gpt-5.6-terra",
+ id: "gpt-5.6-terra",
+ displayName: "5.6 Terra",
+ supportedReasoningEfforts: [
+ { reasoningEffort: "low" },
+ { reasoningEffort: "medium" },
+ ],
+ hidden: false,
+ });
+ assert.equal(JSON.stringify(metadata.availableModels).includes("sk-this-must-not-cross-the-relay"), false);
+ assert.equal(JSON.stringify(metadata.availableModels).includes("capabilities"), false);
+ await adapter.dispose();
+});
+
+test("IPC adapter publishes a snapshot when only thread settings metadata changes", async () => {
+ const state = fixtureState();
+ state.latestModel = "gpt-5.6-terra";
+ state.latestReasoningEffort = "medium";
+ state.latestThreadSettings = { model: "gpt-5.6-terra", effort: "medium" };
+ const client = new FakeIpcClient(state);
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ await new Promise((resolve) => setImmediate(resolve));
+ events.length = 0;
+
+ const updated = structuredClone(state);
+ updated.latestModel = "gpt-5.6-sol";
+ updated.latestReasoningEffort = "ultra";
+ updated.latestThreadSettings = { model: "gpt-5.6-sol", effort: "ultra" };
+ client.emitState(updated, 2, "patches");
+ await new Promise((resolve) => setImmediate(resolve));
+
+ const snapshots = events.filter((event) => event.type === "session.snapshot");
+ assert.equal(snapshots.length, 1);
+ assert.equal(snapshots[0].payload.metadata.latestModel, "gpt-5.6-sol");
+ assert.equal(snapshots[0].payload.metadata.latestReasoningEffort, "ultra");
+ await adapter.dispose();
+});
+
+test("IPC adapter projects official activity, timestamps, command details, and turn duration", async () => {
+ const turnStartedAtMs = Date.now() - 20_000;
+ const finalAssistantStartedAtMs = turnStartedAtMs + 15_000;
+ const state = fixtureState();
+ state.turns[0] = {
+ id: "turn-timed",
+ status: "completed",
+ turnStartedAtMs,
+ finalAssistantStartedAtMs,
+ durationMs: 18_000,
+ commandExecutionStartedAtMsById: { "command-1": turnStartedAtMs + 2_000 },
+ items: [
+ { type: "userMessage", id: "user-timed", content: [{ type: "text", text: "**run** it" }] },
+ {
+ type: "commandExecution",
+ id: "command-1",
+ command: ["/bin/zsh", "-lc", "echo status-test"],
+ commandActions: [
+ { type: "unknown", command: "/bin/zsh" },
+ { type: "unknown", cmd: "echo status-test" },
+ ],
+ cwd: "/tmp/workspace",
+ shellName: "zsh",
+ status: "completed",
+ aggregatedOutput: "status-test",
+ durationMs: 250,
+ exitCode: 0,
+ },
+ { type: "agentMessage", id: "agent-timed", text: "done", phase: "final_answer" },
+ ],
+ };
+ const client = new FakeIpcClient(state);
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.status.activity, "completed");
+ assert.equal(snapshot.durationMs, 18_000);
+ const [user, command, assistant] = snapshot.messages;
+ assert.equal(user.startedAtMs, turnStartedAtMs);
+ assert.equal(command.command, "echo status-test");
+ assert.deepEqual(command.commandActions, [
+ { type: "unknown", command: "/bin/zsh", cmd: "/bin/zsh" },
+ { type: "unknown", cmd: "echo status-test", command: "echo status-test" },
+ ]);
+ assert.equal(command.cwd, "/tmp/workspace");
+ assert.equal(command.shellName, "zsh");
+ assert.equal(command.startedAtMs, turnStartedAtMs + 2_000);
+ assert.equal(command.durationMs, 250);
+ assert.equal(command.exitCode, 0);
+ assert.equal(assistant.startedAtMs, finalAssistantStartedAtMs);
+ assert.equal(assistant.durationMs, 18_000);
+
+ const active = structuredClone(state);
+ active.turns[0].status = "inProgress";
+ delete active.turns[0].durationMs;
+ active.turns[0].items = [{ type: "reasoning", id: "reasoning-1", summary: ["checking"] }];
+ client.emitState(active, 2, "patches");
+ const activeSnapshot = await adapter.snapshot();
+ assert.equal(activeSnapshot.activity, "thinking");
+ assert.equal(activeSnapshot.turnStatus, "inprogress");
+ assert.equal(activeSnapshot.turnId, "turn-timed");
+ assert.equal(activeSnapshot.messages[0].turnStatus, "inProgress");
+
+ active.turns[0].items = [{
+ type: "fileChange",
+ id: "edit-1",
+ status: "inProgress",
+ changes: [{ path: "src/example.ts", diff: "+const remote = true;" }],
+ }];
+ client.emitState(active, 3, "patches");
+ const editingSnapshot = await adapter.snapshot();
+ assert.equal(editingSnapshot.activity, "editing");
+
+ active.turns[0].items = [{
+ type: "commandExecution",
+ id: "command-active",
+ status: "inProgress",
+ command: "echo running",
+ }];
+ client.emitState(active, 4, "patches");
+ const runningSnapshot = await adapter.snapshot();
+ assert.equal(runningSnapshot.activity, "running");
+
+ active.requests = [{
+ id: "approval-active",
+ method: "item/commandExecution/requestApproval",
+ params: { threadId: THREAD_ID, turnId: "turn-timed", command: "echo approve" },
+ }];
+ client.emitState(active, 5, "patches");
+ const waitingSnapshot = await adapter.snapshot();
+ assert.equal(waitingSnapshot.activity, "waiting_approval");
+ await adapter.dispose();
+});
+
+test("IPC adapter keeps command-action-only items visible and suppresses shell bootstraps", async () => {
+ const state = fixtureState();
+ state.turns[0] = {
+ id: "turn-command-actions",
+ status: "inProgress",
+ items: [
+ {
+ // Older snapshots can expose a generic shell item while retaining
+ // the official commandActions payload.
+ type: "shell",
+ id: "command-actions-only",
+ status: "inProgress",
+ aggregatedOutput: "",
+ commandActions: [
+ { type: "unknown", command: "/bin/zsh" },
+ { type: "search", cmd: "rg --files", path: "src" },
+ ],
+ cwd: "/tmp/workspace",
+ shellName: "zsh",
+ },
+ {
+ type: "commandExecution",
+ id: "shell-bootstrap-only",
+ status: "inProgress",
+ aggregatedOutput: "",
+ command: "/bin/zsh",
+ commandActions: [{ type: "unknown", command: "/bin/zsh" }],
+ },
+ {
+ type: "commandExecution",
+ id: "wrapped-command",
+ status: "inProgress",
+ aggregatedOutput: "",
+ command: "/bin/zsh -lc 'printf wrapped'",
+ },
+ {
+ type: "commandExecution",
+ id: "wrapped-action",
+ status: "inProgress",
+ aggregatedOutput: "",
+ commandActions: ["/bin/zsh", "/bin/zsh -lc 'printf action-wrapped'"],
+ },
+ ],
+ };
+ const client = new FakeIpcClient(state);
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.messages.length, 3);
+ const actionsOnly = snapshot.messages.find((message) => message.itemId === "command-actions-only");
+ assert.ok(actionsOnly);
+ assert.equal(actionsOnly.command, "rg --files");
+ assert.equal(actionsOnly.text, "rg --files");
+ assert.equal(actionsOnly.cwd, "/tmp/workspace");
+ assert.equal(actionsOnly.shellName, "zsh");
+ const wrapped = snapshot.messages.find((message) => message.itemId === "wrapped-command");
+ assert.ok(wrapped);
+ assert.equal(wrapped.command, "'printf wrapped'");
+ assert.equal(wrapped.text, "'printf wrapped'");
+ const wrappedAction = snapshot.messages.find((message) => message.itemId === "wrapped-action");
+ assert.ok(wrappedAction);
+ assert.equal(wrappedAction.command, "'printf action-wrapped'");
+ assert.equal(wrappedAction.text, "'printf action-wrapped'");
+ assert.doesNotMatch(snapshot.outputTail, /\/bin\/zsh/);
+ await adapter.dispose();
+});
+
+test("IPC adapter projects collab items, metadata envelopes, and subagent lifecycle", async () => {
+ const childThreadId = "22222222-2222-4222-8222-222222222222";
+ const state = fixtureState();
+ state.id = THREAD_ID;
+ state.turns[0] = {
+ id: "turn-subagents",
+ status: "inProgress",
+ turnStartedAtMs: Date.now() - 2_000,
+ items: [
+ { type: "userMessage", id: "user-subagents", content: [{ type: "text", text: "inspect this" }] },
+ {
+ type: "agentMessage",
+ id: "message-with-collab-metadata",
+ text: "I am delegating this review",
+ metadata: {
+ codex_collab_agent_tool_call: {
+ type: "collabAgentToolCall",
+ id: "collab-spawn-1",
+ tool: "spawnAgent",
+ status: "inProgress",
+ senderThreadId: THREAD_ID,
+ receiverThreadIds: [childThreadId],
+ prompt: "Inspect the tests",
+ model: "gpt-5.6",
+ reasoningEffort: "high",
+ agentsStates: { [childThreadId]: { status: "running", message: null } },
+ },
+ },
+ },
+ {
+ type: "subAgentActivity",
+ id: "activity-started-1",
+ kind: "started",
+ agentThreadId: childThreadId,
+ agentPath: "root/reviewer_agent",
+ },
+ ],
+ };
+ const client = new FakeIpcClient(state);
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+
+ let snapshot = await adapter.snapshot();
+ assert.equal(snapshot.subagents.length, 1);
+ assert.equal(snapshot.subagents[0].threadId, childThreadId);
+ assert.equal(snapshot.subagents[0].displayName, "Reviewer agent");
+ assert.equal(snapshot.subagents[0].prompt, "Inspect the tests");
+ assert.equal(snapshot.subagents[0].objective, "Inspect the tests");
+ assert.equal(snapshot.subagents[0].status, "working");
+ assert.equal(snapshot.subagents[0].model, "gpt-5.6");
+ assert.equal(snapshot.subagents[0].canInteract, true);
+ assert.ok(snapshot.messages.some((message) => message.itemType === "collabAgentToolCall" && message.uiType === "multi-agent-action"));
+ assert.ok(snapshot.messages.some((message) => message.itemType === "subAgentActivity" && message.uiType === "subagent-activity"));
+
+ const completed = structuredClone(state);
+ completed.turns[0].status = "completed";
+ completed.turns[0].items.push({
+ type: "collabAgentToolCall",
+ id: "collab-wait-1",
+ tool: "wait",
+ status: "completed",
+ senderThreadId: THREAD_ID,
+ receiverThreadIds: [],
+ prompt: null,
+ model: null,
+ reasoningEffort: null,
+ agentsStates: {},
+ });
+ client.emitState(completed, 2, "patches");
+ snapshot = await adapter.snapshot();
+ assert.equal(snapshot.subagents[0].status, "done");
+ await adapter.dispose();
+});
+
+test("IPC adapter discovers pending requests retained inside official turn items", async () => {
+ const state = fixtureState();
+ state.turns[0].status = "inProgress";
+ state.turns[0].items.push({
+ type: "permission-request",
+ id: "permission-in-turn",
+ threadId: THREAD_ID,
+ turnId: "turn-old",
+ summary: "需要访问工作区",
+ permissions: { fileSystem: { write: true } },
+ });
+ const client = new FakeIpcClient(state);
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.pendingRequests.length, 1);
+ assert.equal(snapshot.pendingRequests[0].requestId, "permission-in-turn");
+ assert.equal(snapshot.pendingRequests[0].method, "item/permissions/requestApproval");
+ assert.deepEqual(snapshot.pendingRequests[0].params.permissions, { fileSystem: { write: true } });
+ assert.equal(snapshot.messages.some((message) => message && message.itemId === "permission-in-turn"), false);
+ await adapter.dispose();
+});
+
+test("IPC adapter expires unanswered requests and rolls back a failed follow", async () => {
+ const client = new FakeIpcClient(fixtureState([{
+ id: 8,
+ method: "item/commandExecution/requestApproval",
+ params: { threadId: THREAD_ID, command: "echo timeout" },
+ }]));
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 25,
+ followTimeoutMs: 500,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ assert.ok(events.some((event) => event.type === "approval.expired" && event.requestId === 8));
+ assert.equal((await adapter.snapshot()).pendingApprovals.length, 0);
+ const expiryResponse = client.calls.find((call) => call.method === "respondCommandApproval");
+ assert.equal(expiryResponse.decision, "decline");
+ await adapter.dispose();
+
+ const noSnapshotClient = new FakeIpcClient(fixtureState(), false);
+ const failed = new CodexIpcAgentAdapter({
+ client: noSnapshotClient,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 15,
+ });
+ await assert.rejects(() => failed.start(), /Timed out waiting for a snapshot/);
+ assert.equal((await failed.snapshot()).state, "disconnected");
+ assert.equal((await failed.snapshot()).threadId, null);
+ await failed.dispose();
+});
+
+test("IPC adapter stays available while waiting for the first VS Code Codex session", async (t) => {
+ // An empty rollout directory represents a freshly opened VS Code window
+ // whose Codex panel has not created/selected a conversation yet. Starting
+ // the relay must remain possible so a later panel navigation can attach
+ // without restarting the bridge.
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-no-session-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const client = new FakeIpcClient(fixtureState());
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ ownerDiscoveryTimeoutMs: 25,
+ followTimeoutMs: 50,
+ });
+ adapter.onEvent((event) => events.push(event));
+
+ await assert.doesNotReject(() => adapter.start());
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.threadId, null);
+ assert.equal(snapshot.state, "waiting_for_host");
+ assert.equal(snapshot.metadata?.waitingForSession, true);
+ assert.equal(snapshot.metadata?.attachReady, false);
+ assert.equal(client.calls.some((call) => call.method === "followConversation" && call.following === true), false);
+ assert.ok(events.some((event) => event.type === "connection.opened" && event.threadId === undefined));
+
+ await adapter.dispose();
+});
+
+test("IPC adapter attaches in-place when the official panel selects a session after waiting", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-route-after-wait-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const client = new FakeIpcClient(fixtureState());
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ ownerDiscoveryTimeoutMs: 100,
+ followTimeoutMs: 250,
+ });
+ adapter.onEvent((event) => events.push(event));
+
+ await adapter.start();
+ assert.equal((await adapter.snapshot()).state, "waiting_for_host");
+ // The official webview broadcasts this untargeted route update when the
+ // user opens a conversation. The bridge should attach over the same IPC
+ // client rather than asking the user to restart the command.
+ client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
+ await waitFor(async () => (await adapter.snapshot()).threadId === THREAD_ID);
+
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.state, "idle");
+ assert.equal(snapshot.metadata.waitingForSession, false);
+ assert.equal(snapshot.metadata.attachReady, true);
+ assert.equal(client.calls.filter((call) => call.method === "followConversation" && call.following === true).length, 1);
+ assert.equal(events.filter((event) => event.type === "connection.opened").length, 1);
+ assert.ok(events.some((event) => event.type === "session.snapshot" && event.threadId === THREAD_ID));
+ await adapter.dispose();
+});
+
+test("IPC waiting attach follows the latest official route when selection changes mid-snapshot", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-wait-route-race-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "latest route" }],
+ ]);
+ const client = new WaitingRouteRaceIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ ownerDiscoveryTimeoutMs: 100,
+ followTimeoutMs: 250,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+
+ await adapter.start();
+ client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
+ await waitFor(() => client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === THREAD_ID && call.following === true));
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
+ await waitFor(async () => {
+ const snapshot = await adapter.snapshot();
+ return snapshot.threadId === SECOND_THREAD_ID && snapshot.metadata.attachReady === true;
+ });
+
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.metadata.attachReady, true);
+ assert.equal(snapshot.metadata.title, "latest route");
+ assert.ok(client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === SECOND_THREAD_ID && call.following === true));
+ await adapter.dispose();
+});
+
+test("IPC waiting discovery never overrides a newer official VS Code route", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-wait-poll-route-race-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "stale fallback" }],
+ ]);
+ const client = new WaitingPollRouteRaceIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ ownerDiscoveryTimeoutMs: 100,
+ followTimeoutMs: 250,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+
+ await adapter.start();
+ assert.equal((await adapter.snapshot()).state, "waiting_for_host");
+
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+ await fs.writeFile(path.join(sessions, `rollout-${SECOND_THREAD_ID}.jsonl`), `${JSON.stringify({
+ type: "session_meta",
+ payload: { originator: "codex_vscode", source: "vscode", cwd: "/tmp/stale-fallback" },
+ })}\n`);
+
+ client.delayFallbackDiscovery = true;
+ const fallbackDiscovery = adapter.runWaitingDiscovery();
+ await waitFor(() => client.fallbackDiscoveryStarted);
+ client.emitFollowing(THREAD_ID, true, { sourceClientId: "official-vscode-panel" });
+ await waitFor(() => client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === THREAD_ID && call.following === true));
+ client.releaseFallbackDiscovery();
+ await fallbackDiscovery;
+ await waitFor(async () => (await adapter.snapshot()).metadata.attachReady === true);
+
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.threadId, THREAD_ID);
+ assert.equal(snapshot.metadata.title, "fixture session");
+ assert.equal(client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === SECOND_THREAD_ID && call.following === true), false);
+ await adapter.dispose();
+});
+
+test("IPC adapter waits when the configured VS Code conversation has no live owner", async () => {
+ const client = new FakeIpcClient(fixtureState());
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: STALE_THREAD_ID,
+ autoDiscoverThread: false,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ ownerDiscoveryTimeoutMs: 25,
+ followTimeoutMs: 50,
+ });
+
+ await assert.doesNotReject(() => adapter.start());
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.threadId, null);
+ assert.equal(snapshot.state, "waiting_for_host");
+ assert.equal(snapshot.metadata?.waitingForSession, true);
+ assert.equal(snapshot.metadata?.attachReady, false);
+ assert.ok(client.calls.some((call) => call.method === "findThreadOwner" && call.threadId === STALE_THREAD_ID));
+ assert.equal(client.calls.some((call) => call.method === "followConversation" && call.following === true), false);
+
+ await adapter.dispose();
+});
+
+test("IPC adapter preserves official request timestamps and streams a sliding output tail as a delta", async () => {
+ const startedAtMs = Date.now() - 200;
+ const initial = fixtureState([{
+ id: 9,
+ method: "item/commandExecution/requestApproval",
+ createdAt: Date.now(),
+ params: { threadId: THREAD_ID, command: "echo timestamp", startedAtMs },
+ }]);
+ initial.turns[0].items = [{ type: "agentMessage", id: "streaming", text: "x".repeat(40) }];
+ const client = new FakeIpcClient(initial);
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 10_000,
+ maxOutputTailChars: 32,
+ followTimeoutMs: 500,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ const pending = (await adapter.snapshot()).pendingApprovals[0];
+ assert.equal(pending.createdAt, startedAtMs);
+
+ const next = structuredClone(initial);
+ next.turns[0].items[0].text += "y";
+ client.emitState(next, 2);
+ const outputEvents = events.filter((event) => event.type === "output.snapshot" || event.type === "output.chunk");
+ assert.equal(outputEvents.at(-1).type, "output.chunk");
+ assert.equal(outputEvents.at(-1).payload.text, "y");
+ assert.equal(outputEvents.at(-1).payload.messages, undefined);
+ assert.equal(outputEvents.at(-1).payload.messagesPatch.start, 0);
+ assert.equal(outputEvents.at(-1).payload.messagesPatch.deleteCount, 1);
+ assert.equal(outputEvents.at(-1).payload.messagesPatch.messages[0].text, `${"x".repeat(40)}y`);
+
+ // The bounded tail can remain byte-for-byte identical when a repeated
+ // character arrives. It must still advance by one chunk using total length.
+ const repeated = structuredClone(next);
+ repeated.turns[0].items[0].text += "x";
+ client.emitState(repeated, 3);
+ const repeatedEvent = events.filter((event) => event.type === "output.snapshot" || event.type === "output.chunk").at(-1);
+ assert.equal(repeatedEvent.type, "output.chunk");
+ assert.equal(repeatedEvent.payload.text, "x");
+ assert.equal(repeatedEvent.payload.messagesPatch.messages[0].text, `${"x".repeat(40)}yx`);
+ await adapter.dispose();
+});
+
+test("IPC adapter normalizes epoch-second approval timestamps", async () => {
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const startedSeconds = nowSeconds - 2;
+ const expiresSeconds = nowSeconds + 60;
+ const initial = fixtureState([
+ {
+ id: 10,
+ method: "item/commandExecution/requestApproval",
+ createdAt: startedSeconds,
+ expiresAt: expiresSeconds,
+ params: { threadId: THREAD_ID, command: "echo outer seconds" },
+ },
+ {
+ id: 11,
+ method: "item/commandExecution/requestApproval",
+ params: {
+ threadId: THREAD_ID,
+ command: "echo nested seconds",
+ startedAt: String(startedSeconds),
+ expiresAt: String(expiresSeconds),
+ },
+ },
+ ]);
+ const client = new FakeIpcClient(initial);
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 10_000,
+ followTimeoutMs: 500,
+ });
+ adapter.onEvent((event) => events.push(event));
+
+ await adapter.start();
+ const pending = (await adapter.snapshot()).pendingApprovals;
+ assert.equal(pending.length, 2);
+ for (const approval of pending) {
+ assert.equal(approval.createdAt, startedSeconds * 1000);
+ assert.equal(approval.expiresAt, expiresSeconds * 1000);
+ }
+ assert.equal(events.some((event) => event.type === "approval.expired"), false);
+ await adapter.dispose();
+});
+
+test("IPC adapter waits for the revision returned by complete-history loading", async () => {
+ const initial = fixtureState();
+ initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
+ const complete = fixtureState();
+ complete.turnsPagination = { olderCursor: null, hasLoadedOldest: true, isLoadingOlder: false };
+ class HistoryClient extends FakeIpcClient {
+ async loadCompleteHistory() {
+ this.calls.push({ method: "loadCompleteHistory" });
+ // The owner can acknowledge the request before the stream broadcast.
+ // An unrelated owner's revision must not release our waiter.
+ setTimeout(() => this.emitState(initial, 2, "snapshot", THREAD_ID, "other-owner"), 0);
+ setTimeout(() => this.emitState(complete, 2, "snapshot", THREAD_ID, "owner"), 10);
+ return { revision: 2 };
+ }
+ }
+ const client = new HistoryClient(initial);
+ const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
+ await adapter.start();
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ // The unrelated owner event at t=0 must not overwrite the attached stream.
+ assert.equal((await adapter.snapshot()).metadata.historyComplete, false);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.metadata.historyComplete, true);
+ assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 1);
+ await adapter.dispose();
+});
+
+test("IPC adapter retries complete-history loading once after a transient failure", async () => {
+ const initial = fixtureState();
+ initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
+ const complete = fixtureState();
+ complete.turnsPagination = { olderCursor: null, hasLoadedOldest: true, isLoadingOlder: false };
+ class RetryingHistoryClient extends FakeIpcClient {
+ async loadCompleteHistory() {
+ this.calls.push({ method: "loadCompleteHistory" });
+ const attempts = this.calls.filter((call) => call.method === "loadCompleteHistory").length;
+ if (attempts === 1) throw Object.assign(new Error("history request timed out"), { code: "timeout" });
+ setTimeout(() => this.emitState(complete, 2, "snapshot", THREAD_ID, "owner"), 5);
+ return { revision: 2 };
+ }
+ }
+ const client = new RetryingHistoryClient(initial);
+ const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
+ await adapter.start();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 2);
+ assert.equal((await adapter.snapshot()).metadata.historyComplete, true);
+ await adapter.dispose();
+});
+
+test("IPC adapter cancels a pending history retry when switching sessions", async () => {
+ const initial = fixtureState();
+ initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
+ const states = new Map([[THREAD_ID, initial], [SECOND_THREAD_ID, fixtureState()]]);
+ class SwitchingHistoryClient extends MultiSessionIpcClient {
+ async loadCompleteHistory(threadId) {
+ this.calls.push({ method: "loadCompleteHistory", threadId });
+ throw Object.assign(new Error("history request timed out"), { code: "timeout" });
+ }
+ }
+ const client = new SwitchingHistoryClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
+ await adapter.start();
+ await new Promise((resolve) => setImmediate(resolve));
+ await adapter.selectSession({ threadId: SECOND_THREAD_ID });
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ assert.deepEqual(client.calls.filter((call) => call.method === "loadCompleteHistory").map((call) => call.threadId), [THREAD_ID]);
+ assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID);
+ await adapter.dispose();
+});
+
+test("IPC adapter cancels a pending history retry when disposed", async () => {
+ const initial = fixtureState();
+ initial.turnsPagination = { olderCursor: "older", hasLoadedOldest: false, isLoadingOlder: false };
+ class DisposedHistoryClient extends FakeIpcClient {
+ async loadCompleteHistory() {
+ this.calls.push({ method: "loadCompleteHistory" });
+ throw Object.assign(new Error("history request timed out"), { code: "timeout" });
+ }
+ }
+ const client = new DisposedHistoryClient(initial);
+ const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, followTimeoutMs: 500, approvalTimeoutMs: 0 });
+ await adapter.start();
+ await new Promise((resolve) => setImmediate(resolve));
+ await adapter.dispose();
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ assert.equal(client.calls.filter((call) => call.method === "loadCompleteHistory").length, 1);
+});
+
+test("IPC adapter reports canonical history incomplete until one complete island and all item pages", async () => {
+ const state = fixtureState();
+ state.turns = [];
+ state.turnHistory = {
+ kind: "canonical",
+ history: {
+ isComplete: true,
+ islands: [{ entries: [] }, { entries: [] }],
+ entitiesByKey: {
+ "turn:1": {
+ id: "turn-1",
+ status: "completed",
+ items: [],
+ itemsPagination: { hasLoadedOldest: true },
+ },
+ },
+ },
+ };
+ const client = new FakeIpcClient(state);
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ assert.equal((await adapter.snapshot()).metadata.historyComplete, false);
+
+ const itemPagePending = structuredClone(state);
+ itemPagePending.turnHistory.history.islands = [{ entries: [] }];
+ itemPagePending.turnHistory.history.entitiesByKey["turn:1"].itemsPagination.hasLoadedOldest = false;
+ client.emitState(itemPagePending, 2);
+ assert.equal((await adapter.snapshot()).metadata.historyComplete, false);
+
+ const complete = structuredClone(itemPagePending);
+ complete.turnHistory.history.entitiesByKey["turn:1"].itemsPagination.hasLoadedOldest = true;
+ events.length = 0;
+ client.emitState(complete, 3, "patches");
+ assert.equal((await adapter.snapshot()).metadata.historyComplete, true);
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.ok(events.some((event) => event.type === "session.snapshot"));
+ await adapter.dispose();
+});
+
+test("IPC auto-discovery excludes subagents and Codex Desktop tasks", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-discovery-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+
+ const preferredCwd = path.join(codexHome, "current-workspace");
+ const subagentId = "22222222-2222-4222-8222-222222222222";
+ const otherWorkspaceId = "33333333-3333-4333-8333-333333333333";
+ const matchingDesktopId = "44444444-4444-4444-8444-444444444444";
+ const matchingVscodeId = "55555555-5555-4555-8555-555555555555";
+ const writeRollout = async (id, payload, mtimeSeconds) => {
+ const fileName = path.join(sessions, `rollout-2026-08-28T00-00-00-${id}.jsonl`);
+ await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`);
+ await fs.utimes(fileName, mtimeSeconds, mtimeSeconds);
+ };
+
+ await writeRollout(subagentId, {
+ originator: "codex_vscode",
+ source: { subagent: { thread_spawn: {} } },
+ thread_source: "subagent",
+ cwd: preferredCwd,
+ }, 3_000);
+ await writeRollout(otherWorkspaceId, {
+ originator: "codex_vscode",
+ source: "vscode",
+ thread_source: "user",
+ cwd: path.join(codexHome, "other-workspace"),
+ }, 2_000);
+ await writeRollout(matchingDesktopId, {
+ originator: "Codex Desktop",
+ source: "vscode",
+ thread_source: "user",
+ cwd: preferredCwd,
+ }, 4_000);
+ await writeRollout(matchingVscodeId, {
+ originator: "codex_vscode",
+ source: "vscode",
+ thread_source: "user",
+ cwd: preferredCwd,
+ }, 1_000);
+
+ const client = new DiscoveryIpcClient(fixtureState());
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ codexHome,
+ preferredCwds: [preferredCwd],
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+
+ await adapter.start();
+ assert.equal((await adapter.snapshot()).threadId, matchingVscodeId);
+ const discovered = client.calls
+ .filter((call) => call.method === "findThreadOwner")
+ .map((call) => call.threadId);
+ assert.equal(discovered.includes(subagentId), false);
+ assert.equal(discovered.includes(matchingDesktopId), false);
+ await adapter.dispose();
+});
+
+test("IPC auto-discovery finds an older live conversation beyond the first twelve rollouts", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-deep-discovery-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+ const staleIds = Array.from({ length: 20 }, (_, index) => `aaaaaaaa-aaaa-4aaa-8aaa-${String(index).padStart(12, "0")}`);
+ for (const [index, id] of staleIds.entries()) {
+ await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({
+ type: "session_meta",
+ payload: {
+ originator: "codex_vscode",
+ source: "vscode",
+ cwd: `/tmp/stale-${index}`,
+ updated_at: `2026-08-29T${String(20 - index).padStart(2, "0")}:00:00Z`,
+ },
+ })}\n`);
+ }
+ const liveRollout = path.join(sessions, `rollout-${THREAD_ID}.jsonl`);
+ await fs.writeFile(liveRollout, `${JSON.stringify({
+ type: "session_meta",
+ payload: {
+ originator: "codex_vscode",
+ source: "vscode",
+ cwd: "/tmp/live-old",
+ updated_at: "2026-08-01T00:00:00Z",
+ },
+ })}\n`);
+ const oldMtime = new Date("2026-08-01T00:00:00Z");
+ await fs.utimes(liveRollout, oldMtime, oldMtime);
+
+ const client = new FakeIpcClient(fixtureState());
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+
+ assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
+ const discoveryCalls = client.calls.filter((call) => call.method === "findThreadOwner");
+ assert.ok(discoveryCalls.length > 12);
+ assert.ok(discoveryCalls.some((call) => call.threadId === THREAD_ID));
+ await adapter.dispose();
+});
+
+test("IPC adapter lists only indexed VS Code sessions with live attachable snapshots", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-list-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+ const writeRollout = async (id, payload) => {
+ const fileName = path.join(sessions, `rollout-${id}.jsonl`);
+ await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`);
+ };
+ await writeRollout(THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/a" });
+ await writeRollout(SECOND_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/b" });
+ await writeRollout(STALE_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/c" });
+ await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [
+ JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-29T10:00:00Z" }),
+ JSON.stringify({ id: SECOND_THREAD_ID, thread_name: "另一个工作区", updated_at: "2026-08-29T11:00:00Z" }),
+ JSON.stringify({ id: STALE_THREAD_ID, thread_name: "已关闭会话", updated_at: "2026-08-29T12:00:00Z" }),
+ ].join("\n") + "\n");
+
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "另一个工作区" }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ const result = await adapter.listSessions({ limit: 10 });
+ assert.equal(result.activeThreadId, THREAD_ID);
+ assert.deepEqual(result.sessions.map((entry) => entry.threadId), [SECOND_THREAD_ID, THREAD_ID]);
+ assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID).available, true);
+ assert.equal(result.sessions.find((entry) => entry.threadId === STALE_THREAD_ID), undefined);
+ assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID).title, "另一个工作区");
+ assert.ok(client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === SECOND_THREAD_ID && call.following === true));
+ assert.ok(client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === SECOND_THREAD_ID && call.following === false));
+ await adapter.dispose();
+});
+
+test("IPC session list keeps the active attachment when newer stale history fills the limit", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-limit-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+ const writeRollout = async (id, cwd) => {
+ await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({
+ type: "session_meta",
+ payload: { originator: "codex_vscode", source: "vscode", cwd },
+ })}\n`);
+ };
+ await writeRollout(THREAD_ID, "/tmp/current");
+ await writeRollout(STALE_THREAD_ID, "/tmp/stale");
+ await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [
+ JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-28T10:00:00Z" }),
+ JSON.stringify({ id: STALE_THREAD_ID, thread_name: "较新的失效记录", updated_at: "2026-08-29T12:00:00Z" }),
+ ].join("\n") + "\n");
+
+ const client = new MultiSessionIpcClient(new Map([[THREAD_ID, fixtureState()]]), { [THREAD_ID]: "owner-a" });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ const result = await adapter.listSessions({ limit: 1 });
+ assert.deepEqual(result.sessions.map((entry) => entry.threadId), [THREAD_ID]);
+ assert.equal(result.sessions[0].active, true);
+ await adapter.dispose();
+});
+
+test("IPC session list omits an owner that does not return a matching snapshot", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-probe-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+ const writeRollout = async (id, payload) => {
+ const fileName = path.join(sessions, `rollout-${id}.jsonl`);
+ await fs.writeFile(fileName, `${JSON.stringify({ type: "session_meta", payload })}\n`);
+ };
+ await writeRollout(THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/a" });
+ await writeRollout(SECOND_THREAD_ID, { originator: "codex_vscode", source: "vscode", cwd: "/tmp/b" });
+ await fs.writeFile(path.join(codexHome, "session_index.jsonl"), [
+ JSON.stringify({ id: THREAD_ID, thread_name: "当前会话", updated_at: "2026-08-29T10:00:00Z" }),
+ JSON.stringify({ id: SECOND_THREAD_ID, thread_name: "桌面会话", updated_at: "2026-08-29T11:00:00Z" }),
+ ].join("\n") + "\n");
+
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "桌面会话" }],
+ ]);
+ const client = new NoSnapshotSwitchIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "desktop-owner",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ ownerDiscoveryTimeoutMs: 250,
+ followTimeoutMs: 250,
+ });
+ await adapter.start();
+ const result = await adapter.listSessions({ limit: 10 });
+ assert.equal(result.sessions.find((entry) => entry.threadId === THREAD_ID).available, true);
+ assert.equal(result.sessions.find((entry) => entry.threadId === SECOND_THREAD_ID), undefined);
+ const targetCalls = client.calls.filter((call) => call.method === "followConversation" && call.threadId === SECOND_THREAD_ID);
+ assert.equal(targetCalls.filter((call) => call.following === true).length, 1);
+ assert.equal(targetCalls.filter((call) => call.following === false).length, 1);
+ await adapter.dispose();
+});
+
+test("IPC adapter switches follows only after the target owner snapshot arrives", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话", turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "target output" }] }] }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ const result = await adapter.selectSession({ threadId: SECOND_THREAD_ID });
+ assert.deepEqual(result, {
+ threadId: SECOND_THREAD_ID,
+ previousThreadId: THREAD_ID,
+ switched: true,
+ available: true,
+ });
+ assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID);
+ assert.match((await adapter.snapshot()).outputTail, /target output/);
+ assert.ok(events.some((event) => event.type === "session.switching"));
+ assert.ok(events.some((event) => event.type === "session.selected"));
+ const oldUnfollow = client.calls.find((call) => call.method === "followConversation" && call.threadId === THREAD_ID && call.following === false);
+ assert.ok(oldUnfollow);
+ await adapter.dispose();
+});
+
+test("IPC adapter follows paired route changes from the attached VS Code panel", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, {
+ ...fixtureState(),
+ id: SECOND_THREAD_ID,
+ title: "面板目标会话",
+ turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "panel target output" }] }],
+ }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "panel-owner",
+ [SECOND_THREAD_ID]: "target-owner",
+ });
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ vscodeSessionFollowDebounceMs: 5,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
+
+ const snapshot = await adapter.snapshot();
+ assert.match(snapshot.outputTail, /panel target output/);
+ assert.ok(events.some((event) => event.type === "session.switching" && event.threadId === SECOND_THREAD_ID));
+ assert.ok(events.some((event) => event.type === "session.selected" && event.threadId === SECOND_THREAD_ID));
+ await adapter.dispose();
+});
+
+test("IPC adapter ignores isolated, targeted, and other-client following broadcasts", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "不应自动切换" }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "panel-owner",
+ [SECOND_THREAD_ID]: "target-owner",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ await adapter.start();
+
+ // Bind the route source with a same-thread leave/re-enter pair.
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(THREAD_ID, true, { sourceClientId: "panel-owner" });
+ // A reconnect/status replay is a lone true and is not a route change.
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ // Targeted replies describe liveness to one follower, not panel navigation.
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner", targetClientIds: ["follower"] });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner", targetClientIds: ["follower"] });
+ // Another Codex window shares the router but cannot take over this bridge.
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "other-panel" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "other-panel" });
+ await new Promise((resolve) => setTimeout(resolve, 30));
+
+ assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
+ assert.equal(client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === SECOND_THREAD_ID && call.following === true), false);
+ await adapter.dispose();
+});
+
+test("IPC adapter does not trust an isolated false from another follower", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "真实面板目标" }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ await adapter.start();
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "disposing-remote-follower" });
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "real-vscode-panel" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "real-vscode-panel" });
+ await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
+ await adapter.dispose();
+});
+
+test("IPC adapter binds the matching route source when another unbound source emits false", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, {
+ ...fixtureState(),
+ id: SECOND_THREAD_ID,
+ title: "真实面板目标",
+ turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "real panel target" }] }],
+ }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ await adapter.start();
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "real-vscode-panel" });
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "other-follower" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "real-vscode-panel" });
+ await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
+
+ assert.match((await adapter.snapshot()).outputTail, /real panel target/);
+ await adapter.dispose();
+});
+
+test("IPC adapter lets a replacement route source take over after the bound client disconnects", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, {
+ ...fixtureState(),
+ id: SECOND_THREAD_ID,
+ title: "重连后的目标",
+ turns: [{ id: "turn-target", status: "completed", items: [{ type: "agentMessage", id: "target-message", text: "replacement panel target" }] }],
+ }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ await adapter.start();
+
+ // First bind the official panel without changing the selected conversation.
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "old-vscode-panel" });
+ client.emitFollowing(THREAD_ID, true, { sourceClientId: "old-vscode-panel" });
+ client.emitClientStatus("old-vscode-panel", "disconnected");
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "replacement-vscode-panel" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "replacement-vscode-panel" });
+ await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID);
+
+ assert.match((await adapter.snapshot()).outputTail, /replacement panel target/);
+ await adapter.dispose();
+});
+
+test("IPC adapter coalesces rapid VS Code A to B to C navigation to C", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "中间会话" }],
+ [THIRD_THREAD_ID, {
+ ...fixtureState(),
+ id: THIRD_THREAD_ID,
+ title: "最终会话",
+ turns: [{ id: "turn-c", status: "completed", items: [{ type: "agentMessage", id: "message-c", text: "final C output" }] }],
+ }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "panel-owner",
+ [SECOND_THREAD_ID]: "owner-b",
+ [THIRD_THREAD_ID]: "owner-c",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ vscodeSessionFollowDebounceMs: 20,
+ });
+ await adapter.start();
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ client.emitFollowing(SECOND_THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(THIRD_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ await waitFor(async () => (await adapter.snapshot()).threadId === THIRD_THREAD_ID);
+
+ assert.match((await adapter.snapshot()).outputTail, /final C output/);
+ assert.equal(client.calls.some((call) => call.method === "followConversation"
+ && call.threadId === SECOND_THREAD_ID && call.following === true), false);
+ await adapter.dispose();
+});
+
+test("IPC adapter cancels an in-flight B snapshot when VS Code moves on to C", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照 B" }],
+ [THIRD_THREAD_ID, {
+ ...fixtureState(),
+ id: THIRD_THREAD_ID,
+ title: "最终 C",
+ turns: [{ id: "turn-c", status: "completed", items: [{ type: "agentMessage", id: "message-c", text: "C arrived" }] }],
+ }],
+ ]);
+ const client = new NoSnapshotSwitchIpcClient(states, {
+ [THREAD_ID]: "panel-owner",
+ [SECOND_THREAD_ID]: "owner-b",
+ [THIRD_THREAD_ID]: "owner-c",
+ });
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 1_000,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ await waitFor(() => events.some((event) => event.type === "session.switching" && event.threadId === SECOND_THREAD_ID));
+ const movedOnAt = Date.now();
+ client.emitFollowing(SECOND_THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(THIRD_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ await waitFor(async () => (await adapter.snapshot()).threadId === THIRD_THREAD_ID, 500);
+
+ assert.ok(Date.now() - movedOnAt < 500, "C should not wait for B's 1s snapshot timeout");
+ assert.equal(events.some((event) => event.type === "session.selected"
+ && event.threadId === SECOND_THREAD_ID && event.payload.switched === true), false);
+ assert.match((await adapter.snapshot()).outputTail, /C arrived/);
+ await adapter.dispose();
+});
+
+test("IPC adapter defers the latest VS Code route while the old turn is active", async () => {
+ const active = fixtureState();
+ active.turns[0].status = "inProgress";
+ active.threadRuntimeStatus = { type: "active" };
+ const completed = structuredClone(active);
+ completed.turns[0].status = "completed";
+ completed.threadRuntimeStatus = { type: "idle" };
+ const states = new Map([
+ [THREAD_ID, active],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "延后目标" }],
+ ]);
+ const client = new MultiSessionIpcClient(states, {
+ [THREAD_ID]: "panel-owner",
+ [SECOND_THREAD_ID]: "target-owner",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ await adapter.start();
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
+
+ client.emitState(completed, 2, "patches", THREAD_ID, "panel-owner");
+ await waitFor(async () => (await adapter.snapshot()).threadId === SECOND_THREAD_ID, 1_000);
+ await adapter.dispose();
+});
+
+test("IPC adapter publishes an explicit rollback when a VS Code route cannot stream", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照目标" }],
+ ]);
+ const client = new NoSnapshotSwitchIpcClient(states, {
+ [THREAD_ID]: "panel-owner",
+ [SECOND_THREAD_ID]: "target-owner",
+ });
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 25,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ events.length = 0;
+
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ await waitFor(() => events.some((event) => event.type === "session.selected" && event.payload.failed === true));
+
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.threadId, THREAD_ID);
+ assert.match(snapshot.outputTail, /hi from VS Code/);
+ const rollbackIndex = events.findIndex((event) => event.type === "session.selected" && event.payload.failed === true);
+ const restoredOutputIndex = events.findIndex((event, index) => index > rollbackIndex
+ && event.type === "output.snapshot" && event.threadId === THREAD_ID);
+ assert.ok(rollbackIndex >= 0);
+ assert.ok(restoredOutputIndex > rollbackIndex);
+ await adapter.dispose();
+});
+
+test("IPC adapter keeps dispose authoritative while an automatic switch is waiting", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "等待中的目标" }],
+ ]);
+ const client = new NoSnapshotSwitchIpcClient(states, {
+ [THREAD_ID]: "panel-owner",
+ [SECOND_THREAD_ID]: "target-owner",
+ });
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 1_000,
+ vscodeSessionFollowDebounceMs: 0,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ client.emitFollowing(THREAD_ID, false, { sourceClientId: "panel-owner" });
+ client.emitFollowing(SECOND_THREAD_ID, true, { sourceClientId: "panel-owner" });
+ await waitFor(() => events.some((event) => event.type === "session.switching"));
+
+ const eventCountAtDispose = events.length;
+ await adapter.dispose();
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ assert.equal((await adapter.snapshot()).state, "disconnected");
+ assert.equal(events.slice(eventCountAtDispose).some((event) => [
+ "session.selected",
+ "output.snapshot",
+ "session.snapshot",
+ ].includes(event.type)), false);
+});
+
+test("IPC adapter absorbs a snapshot waiter when target follow fails immediately", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "立即失败" }],
+ ]);
+ const client = new ThrowingTargetFollowIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ await assert.rejects(() => adapter.selectSession({ threadId: SECOND_THREAD_ID }), /target follow failed immediately/);
+ assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
+ await adapter.dispose();
+});
+
+test("IPC adapter rejects a target snapshot when owner changes before commit", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "Owner handoff" }],
+ ]);
+ const client = new OwnerChangingIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ await assert.rejects(
+ () => adapter.selectSession({ threadId: SECOND_THREAD_ID }),
+ /owner changed while switching/,
+ );
+ assert.equal((await adapter.snapshot()).threadId, THREAD_ID);
+ await adapter.dispose();
+});
+
+test("IPC adapter serializes list probes before selecting the same session", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-session-serialization-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+ for (const [id, cwd] of [[THREAD_ID, "/tmp/current"], [SECOND_THREAD_ID, "/tmp/target"]]) {
+ await fs.writeFile(path.join(sessions, `rollout-${id}.jsonl`), `${JSON.stringify({
+ type: "session_meta",
+ payload: { originator: "codex_vscode", source: "vscode", cwd },
+ })}\n`);
+ }
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话" }],
+ ]);
+ const client = new DelayedProbeIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+
+ const listPromise = adapter.listSessions({ limit: 10 });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ const selectPromise = adapter.selectSession({ threadId: SECOND_THREAD_ID });
+ await Promise.all([listPromise, selectPromise]);
+
+ const targetFollowing = client.calls
+ .filter((call) => call.method === "followConversation" && call.threadId === SECOND_THREAD_ID)
+ .map((call) => call.following);
+ assert.deepEqual(targetFollowing, [true, false, true]);
+ assert.equal((await adapter.snapshot()).threadId, SECOND_THREAD_ID);
+ await adapter.dispose();
+});
+
+test("IPC adapter restores the cached previous projection when target follow has no snapshot", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "目标会话" }],
+ ]);
+ const client = new NoSnapshotSwitchIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const events = [];
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 25,
+ });
+ adapter.onEvent((event) => events.push(event));
+ await adapter.start();
+ const before = await adapter.snapshot();
+ assert.equal(before.threadId, THREAD_ID);
+ assert.match(before.outputTail, /hi from VS Code/);
+
+ await assert.rejects(
+ () => adapter.selectSession({ threadId: SECOND_THREAD_ID }),
+ /Timed out waiting for a snapshot from VS Code conversation/,
+ );
+
+ const after = await adapter.snapshot();
+ assert.equal(after.threadId, THREAD_ID);
+ assert.equal(after.outputTail, before.outputTail);
+ assert.deepEqual(after.messages, before.messages);
+ assert.equal(after.state, "idle");
+ assert.ok(events.some((event) => event.type === "output.snapshot" && event.threadId === THREAD_ID));
+ assert.ok(events.some((event) => event.type === "session.snapshot" && event.threadId === THREAD_ID));
+ await adapter.dispose();
+});
+
+test("IPC adapter restores its owner-validated projection after the IPC cache is polluted", async () => {
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, { ...fixtureState(), id: SECOND_THREAD_ID, title: "无快照目标" }],
+ ]);
+ const client = new NoSnapshotSwitchIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 25,
+ });
+ await adapter.start();
+ const verified = await adapter.snapshot();
+
+ const polluted = {
+ ...fixtureState(),
+ title: "来自旧 owner 的污染快照",
+ turns: [{
+ id: "turn-polluted",
+ status: "completed",
+ items: [{ type: "agentMessage", id: "polluted-message", text: "poisoned stale output" }],
+ }],
+ };
+ client.emitState(polluted, 99, "snapshot", THREAD_ID, "old-owner");
+
+ assert.equal(client.getConversationState(THREAD_ID).ownerClientId, "old-owner");
+ assert.equal(client.getConversationState(THREAD_ID).conversationState.title, "来自旧 owner 的污染快照");
+ assert.equal((await adapter.snapshot()).outputTail, verified.outputTail);
+
+ await assert.rejects(
+ () => adapter.selectSession({ threadId: SECOND_THREAD_ID }),
+ /Timed out waiting for a snapshot from VS Code conversation/,
+ );
+
+ const restored = await adapter.snapshot();
+ assert.equal(restored.threadId, THREAD_ID);
+ assert.equal(restored.outputTail, verified.outputTail);
+ assert.deepEqual(restored.messages, verified.messages);
+ assert.doesNotMatch(restored.outputTail, /poisoned stale output/);
+ await adapter.dispose();
+});
+
+test("IPC adapter denies target approvals on dispose and blocks ordinary commands mid-switch", async () => {
+ const targetApprovalId = "target-approval";
+ const targetState = {
+ ...fixtureState([{
+ id: targetApprovalId,
+ method: "item/commandExecution/requestApproval",
+ params: { threadId: SECOND_THREAD_ID, turnId: "turn-target", command: "echo target" },
+ }]),
+ id: SECOND_THREAD_ID,
+ title: "等待 owner 确认的目标",
+ };
+ const states = new Map([
+ [THREAD_ID, fixtureState()],
+ [SECOND_THREAD_ID, targetState],
+ ]);
+ const client = new DelayedTargetOwnerConfirmationIpcClient(states, {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+
+ const switching = adapter.selectSession({ threadId: SECOND_THREAD_ID });
+ void switching.catch(() => undefined);
+ await waitFor(() => client.targetConfirmationStarted);
+ await waitFor(async () => (await adapter.snapshot()).pendingApprovals.some((entry) => entry.requestId === targetApprovalId));
+
+ await assert.rejects(
+ () => adapter.startTurn({ text: "must not be sent while switching" }),
+ /session switch is still in progress/,
+ );
+ await assert.rejects(
+ () => adapter.respondApproval(targetApprovalId, "allow"),
+ /session switch is still in progress/,
+ );
+ assert.equal(client.calls.some((call) => call.method === "startTurn"), false);
+ assert.equal(client.calls.some((call) => call.method === "respondCommandApproval"), false);
+
+ await adapter.dispose();
+ const denied = client.calls.find((call) => call.method === "respondCommandApproval"
+ && call.requestId === targetApprovalId);
+ assert.ok(denied);
+ assert.equal(denied.threadId, SECOND_THREAD_ID);
+ assert.equal(denied.decision, "decline");
+ assert.equal(denied.options.ownerClientId, "owner-b");
+
+ client.releaseTargetOwnerConfirmation();
+ await assert.rejects(switching, /session selection was cancelled because the IPC session closed/);
+});
+
+test("IPC adapter refuses session switching during an active turn or pending request", async () => {
+ const state = fixtureState([{ id: 12, method: "item/commandExecution/requestApproval", params: { command: "echo pending" } }]);
+ const client = new MultiSessionIpcClient(new Map([[THREAD_ID, state], [SECOND_THREAD_ID, fixtureState()]]), {
+ [THREAD_ID]: "owner-a",
+ [SECOND_THREAD_ID]: "owner-b",
+ });
+ const adapter = new CodexIpcAgentAdapter({ client, threadId: THREAD_ID, loadCompleteHistory: false, approvalTimeoutMs: 0, followTimeoutMs: 500 });
+ await adapter.start();
+ await assert.rejects(() => adapter.selectSession({ threadId: SECOND_THREAD_ID }), /turn or approval is active/);
+ await adapter.dispose();
+});
+
+test("IPC session discovery accepts current ULID rollout filenames", async (t) => {
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ipc-ulid-"));
+ t.after(() => fs.rm(codexHome, { recursive: true, force: true }));
+ const sessions = path.join(codexHome, "sessions", "2026", "08");
+ await fs.mkdir(sessions, { recursive: true });
+ await fs.writeFile(path.join(sessions, `rollout-2026-08-29T00-00-00-${ULID_THREAD_ID}.jsonl`), `${JSON.stringify({
+ type: "session_meta",
+ payload: { id: ULID_THREAD_ID, originator: "codex_vscode", source: "vscode", cwd: "/tmp/ulid" },
+ })}\n`);
+ await fs.writeFile(path.join(codexHome, "session_index.jsonl"), `${JSON.stringify({ id: ULID_THREAD_ID, thread_name: "ULID 会话", updated_at: "2026-08-29T12:00:00Z" })}\n`);
+ const client = new DiscoveryIpcClient(fixtureState());
+ const adapter = new CodexIpcAgentAdapter({
+ client,
+ threadId: THREAD_ID,
+ codexHome,
+ loadCompleteHistory: false,
+ approvalTimeoutMs: 0,
+ followTimeoutMs: 500,
+ });
+ await adapter.start();
+ const result = await adapter.listSessions({ limit: 10 });
+ const session = result.sessions.find((entry) => entry.threadId === ULID_THREAD_ID);
+ assert.ok(session);
+ assert.equal(session.title, "ULID 会话");
+ assert.equal(session.available, true);
+ await adapter.dispose();
+});
diff --git a/aether-vscodex/test/codex-ipc.test.js b/aether-vscodex/test/codex-ipc.test.js
new file mode 100644
index 000000000..7bd366b26
--- /dev/null
+++ b/aether-vscodex/test/codex-ipc.test.js
@@ -0,0 +1,209 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const net = require("node:net");
+const os = require("node:os");
+const path = require("node:path");
+const { mkdtempSync, rmSync } = require("node:fs");
+const test = require("node:test");
+
+const {
+ CODEX_IPC_METHOD_VERSIONS,
+ CodexIpcClient,
+ IpcFrameDecoder,
+ applyIpcPatches,
+ encodeIpcFrame,
+} = require("../vscode-extension/dist/codexIpc.js");
+
+function waitFor(predicate, timeoutMs = 2_000) {
+ const started = Date.now();
+ return new Promise((resolve, reject) => {
+ const poll = () => {
+ if (predicate()) return resolve();
+ if (Date.now() - started >= timeoutMs) return reject(new Error("timed out waiting for fixture"));
+ setTimeout(poll, 5);
+ };
+ poll();
+ });
+}
+
+test("private IPC framing handles split UTF-8 frames", () => {
+ const message = {
+ type: "broadcast",
+ method: "thread-stream-following-changed",
+ sourceClientId: "client-1",
+ version: 1,
+ params: { conversationId: "thread-1", hostId: "local", following: true, text: "中文" },
+ };
+ const frame = encodeIpcFrame(message);
+ const decoder = new IpcFrameDecoder();
+ const first = decoder.push(frame.subarray(0, 3));
+ assert.deepEqual(first, []);
+ const second = decoder.push(frame.subarray(3, frame.length - 1));
+ assert.deepEqual(second, []);
+ assert.deepEqual(decoder.push(frame.subarray(frame.length - 1)), [message]);
+});
+
+test("applyIpcPatches updates a conversation snapshot", () => {
+ const initial = { turns: [{ items: [{ text: "old" }] }], status: "idle" };
+ const next = applyIpcPatches(initial, [
+ { op: "replace", path: ["turns", 0, "items", 0, "text"], value: "new" },
+ { op: "add", path: ["turns", 0, "items", 1], value: { text: "second" } },
+ { op: "replace", path: ["status"], value: "active" },
+ ]);
+ assert.deepEqual(next, {
+ turns: [{ items: [{ text: "new" }, { text: "second" }] }],
+ status: "active",
+ });
+});
+
+test("fixture owner receives follow/start/steer/interrupt/approval requests", async () => {
+ const temp = mkdtempSync(path.join(os.tmpdir(), "codex-ipc-fixture-"));
+ const socketPath = path.join(temp, "ipc.sock");
+ const threadId = "11111111-1111-4111-8111-111111111111";
+ const ownerId = "owner-client";
+ const requests = [];
+ const followingBroadcasts = [];
+ let fixtureSocket;
+ const server = net.createServer((socket) => {
+ fixtureSocket = socket;
+ const decoder = new IpcFrameDecoder();
+ socket.on("data", (chunk) => {
+ for (const message of decoder.push(chunk)) {
+ if (message.type === "request" && message.method === "initialize") {
+ socket.write(encodeIpcFrame({
+ type: "response",
+ requestId: message.requestId,
+ resultType: "success",
+ method: "initialize",
+ handledByClientId: "fixture-client",
+ result: { clientId: "fixture-client" },
+ }));
+ continue;
+ }
+ if (message.type === "broadcast" && message.method === "thread-stream-following-changed") {
+ followingBroadcasts.push(message);
+ const target = message.sourceClientId;
+ socket.write(encodeIpcFrame({
+ type: "broadcast",
+ method: "thread-stream-state-changed",
+ sourceClientId: ownerId,
+ targetClientIds: [target],
+ version: CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"],
+ params: {
+ conversationId: threadId,
+ hostId: "local",
+ change: {
+ type: "snapshot",
+ revision: 1,
+ conversationState: { id: threadId, title: "fixture", turns: [], requests: [] },
+ },
+ },
+ }));
+ continue;
+ }
+ if (message.type === "request") {
+ requests.push(message);
+ socket.write(encodeIpcFrame({
+ type: "response",
+ requestId: message.requestId,
+ resultType: "success",
+ method: message.method,
+ handledByClientId: ownerId,
+ result: { method: message.method, ok: true },
+ }));
+ }
+ }
+ });
+ });
+
+ try {
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(socketPath, resolve);
+ });
+ const client = new CodexIpcClient({ socketPath, autoReconnect: false });
+ const streamEvents = [];
+ client.onStreamEvent((event) => streamEvents.push(event));
+ await client.connect();
+ await client.followConversation(threadId);
+ await waitFor(() => streamEvents.some((event) => event.kind === "snapshot"));
+ assert.equal(client.getConversationState(threadId).ownerClientId, ownerId);
+ fixtureSocket.write(encodeIpcFrame({
+ type: "broadcast",
+ method: "thread-stream-following-status-requested",
+ sourceClientId: ownerId,
+ targetClientIds: ["fixture-client"],
+ version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"],
+ params: { conversationId: threadId, hostId: "local" },
+ }));
+ await waitFor(() => followingBroadcasts.length >= 2);
+ assert.deepEqual(followingBroadcasts[1].targetClientIds, [ownerId]);
+ assert.deepEqual(followingBroadcasts[1].params, {
+ conversationId: threadId,
+ hostId: "local",
+ following: true,
+ });
+
+ await client.startTurn(threadId, "hello", { ownerClientId: ownerId });
+ await client.steerTurn(threadId, "follow-up", { ownerClientId: ownerId });
+ await client.updateThreadSettings(threadId, {
+ model: "gpt-5.6-sol",
+ effort: "ultra",
+ multiAgentMode: "explicitRequestOnly",
+ }, { ownerClientId: ownerId });
+ await client.interruptTurn(threadId, { mode: "user-stop", expectedTurnId: "turn-1", ownerClientId: ownerId });
+ await client.respondCommandApproval(threadId, 7, "decline", { ownerClientId: ownerId });
+ await client.respondFileApproval(threadId, "8", "cancel", { ownerClientId: ownerId });
+ await client.respondPermissionsApproval(threadId, 9, { permissions: {}, scope: "turn" }, { ownerClientId: ownerId });
+ await client.respondUserInput(threadId, 10, { answers: {} }, { ownerClientId: ownerId });
+ await client.respondMcpElicitation(threadId, 11, { action: "decline", content: null, _meta: null }, { ownerClientId: ownerId });
+
+ assert.deepEqual(requests.map((request) => request.method), [
+ "thread-follower-start-turn",
+ "thread-follower-steer-turn",
+ "thread-follower-update-thread-settings",
+ "thread-follower-interrupt-turn",
+ "thread-follower-command-approval-decision",
+ "thread-follower-file-approval-decision",
+ "thread-follower-permissions-request-approval-response",
+ "thread-follower-submit-user-input",
+ "thread-follower-submit-mcp-server-elicitation-response",
+ ]);
+ assert.deepEqual(requests[0].params, {
+ conversationId: threadId,
+ turnStart: {
+ request: {
+ threadId,
+ input: [{ type: "text", text: "hello", text_elements: [] }],
+ },
+ context: { inheritThreadSettings: true },
+ },
+ });
+ assert.deepEqual(requests[2].params, {
+ conversationId: threadId,
+ threadSettings: {
+ model: "gpt-5.6-sol",
+ effort: "ultra",
+ multiAgentMode: "explicitRequestOnly",
+ },
+ });
+ assert.equal(requests[2].version, 1);
+ assert.deepEqual(requests[3].params, {
+ conversationId: threadId,
+ mode: "user-stop",
+ expectedTurnId: "turn-1",
+ });
+ assert.equal(requests[3].version, 4);
+ assert.deepEqual(requests[4].params, { conversationId: threadId, requestId: 7, decision: "decline" });
+ assert.deepEqual(requests[8].params, {
+ conversationId: threadId,
+ requestId: 11,
+ response: { action: "decline", content: null, _meta: null },
+ });
+ await client.dispose();
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ rmSync(temp, { recursive: true, force: true });
+ }
+});
diff --git a/aether-vscodex/test/codex-path.test.js b/aether-vscodex/test/codex-path.test.js
new file mode 100644
index 000000000..ed76ad1f6
--- /dev/null
+++ b/aether-vscodex/test/codex-path.test.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+
+const { resolveCodexCommand } = require("../vscode-extension/dist/codexPath.js");
+const { JsonlRpcClient } = require("../vscode-extension/dist/jsonlRpc.js");
+
+function temporaryDirectory() {
+ return mkdtempSync(path.join(os.tmpdir(), "codex-remote-path-"));
+}
+
+test("resolveCodexCommand finds a bare command in PATH", () => {
+ const root = temporaryDirectory();
+ try {
+ const bin = path.join(root, "bin");
+ const executable = path.join(bin, "codex-test");
+ mkdirSync(bin);
+ writeFileSync(executable, "#!/bin/sh\nexit 0\n");
+ chmodSync(executable, 0o755);
+ assert.equal(resolveCodexCommand("codex-test", { env: { PATH: bin }, platform: process.platform }), executable);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("resolveCodexCommand falls back to a per-user ChatGPT.app install", () => {
+ const root = temporaryDirectory();
+ try {
+ const executable = path.join(root, "Applications", "ChatGPT.app", "Contents", "Resources", "codex");
+ mkdirSync(path.dirname(executable), { recursive: true });
+ writeFileSync(executable, "#!/bin/sh\nexit 0\n");
+ chmodSync(executable, 0o755);
+ assert.equal(
+ resolveCodexCommand("codex", { env: { PATH: "/usr/bin:/bin" }, homeDir: root, platform: "darwin" }),
+ executable,
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("a missing explicit command reports a full-path setting hint", () => {
+ assert.throws(
+ () => resolveCodexCommand("/definitely/missing/codex", { platform: process.platform }),
+ /Codex executable .* was not found.*codexRemoteCollab\.codexCommand.*full path/,
+ );
+});
+
+test("JsonlRpcClient turns spawn ENOENT into an actionable error", async () => {
+ const client = new JsonlRpcClient({ command: "/definitely/missing/codex", args: [] });
+ await assert.rejects(() => client.start(), /Codex executable .* was not found.*codexRemoteCollab\.codexCommand/);
+ client.close();
+});
diff --git a/aether-vscodex/test/composite-relay.test.js b/aether-vscodex/test/composite-relay.test.js
new file mode 100644
index 000000000..323c5a879
--- /dev/null
+++ b/aether-vscodex/test/composite-relay.test.js
@@ -0,0 +1,105 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const test = require("node:test");
+
+const { CompositeRelayTransport } = require("../vscode-extension/dist/compositeRelay.js");
+
+class FakeRelay {
+ constructor({ connectError } = {}) {
+ this.connectError = connectError;
+ this.frames = [];
+ this.closed = false;
+ this.listeners = { message: new Set(), open: new Set(), close: new Set() };
+ }
+
+ async connect() {
+ if (this.connectError) throw this.connectError;
+ for (const listener of this.listeners.open) listener();
+ }
+
+ send(frame) { this.frames.push(frame); }
+ close() { this.closed = true; }
+ onMessage(listener) { return this.add("message", listener); }
+ onOpen(listener) { return this.add("open", listener); }
+ onClose(listener) { return this.add("close", listener); }
+ add(type, listener) {
+ this.listeners[type].add(listener);
+ return { dispose: () => this.listeners[type].delete(listener) };
+ }
+ receive(frame) { for (const listener of this.listeners.message) listener(frame); }
+ disconnect(error) { for (const listener of this.listeners.close) listener(error); }
+}
+
+test("CompositeRelayTransport keeps local control available when optional cloud connect fails", async () => {
+ const local = new FakeRelay();
+ const cloud = new FakeRelay({ connectError: new Error("cloud offline") });
+ const relay = new CompositeRelayTransport([
+ { id: "local", transport: local, required: true },
+ { id: "cloud", transport: cloud },
+ ]);
+
+ await relay.connect();
+ assert.equal(relay.isConnected("local"), true);
+ assert.equal(relay.isConnected("cloud"), false);
+ relay.send({ kind: "event", type: "session.snapshot" });
+ assert.equal(local.frames.length, 1);
+ assert.equal(cloud.frames.length, 1, "optional transport may queue events for reconnect");
+ relay.close();
+ assert.equal(local.closed, true);
+ assert.equal(cloud.closed, true);
+});
+
+test("CompositeRelayTransport forwards commands and reports offline only after every relay closes", async () => {
+ const local = new FakeRelay();
+ const cloud = new FakeRelay();
+ const relay = new CompositeRelayTransport([
+ { id: "local", transport: local, required: true },
+ { id: "cloud", transport: cloud },
+ ]);
+ const messages = [];
+ const closes = [];
+ relay.onMessage((frame) => messages.push(frame));
+ relay.onClose((error) => closes.push(error?.message));
+
+ await relay.connect();
+ assert.equal(relay.isConnected("local"), true);
+ assert.equal(relay.isConnected("cloud"), true);
+ cloud.receive({ kind: "command", type: "turn.start" });
+ assert.equal(messages.length, 1);
+ local.disconnect(new Error("local offline"));
+ assert.equal(relay.isConnected("local"), false);
+ assert.deepEqual(closes, []);
+ cloud.disconnect(new Error("cloud offline"));
+ assert.deepEqual(closes, ["cloud offline"]);
+ relay.close();
+});
+
+test("CompositeRelayTransport surfaces each member reconnect for snapshot hydration", async () => {
+ const local = new FakeRelay();
+ const cloud = new FakeRelay();
+ const relay = new CompositeRelayTransport([
+ { id: "local", transport: local, required: true },
+ { id: "cloud", transport: cloud },
+ ]);
+ let opens = 0;
+ relay.onOpen(() => { opens += 1; });
+ await relay.connect();
+ assert.equal(opens, 2);
+ cloud.disconnect(new Error("cloud offline"));
+ for (const listener of cloud.listeners.open) listener();
+ assert.equal(opens, 3, "cloud recovery must prompt RelayHost to publish a fresh snapshot");
+ relay.close();
+});
+
+test("CompositeRelayTransport fails when the required local relay cannot connect", async () => {
+ const local = new FakeRelay({ connectError: new Error("local offline") });
+ const cloud = new FakeRelay();
+ const relay = new CompositeRelayTransport([
+ { id: "local", transport: local, required: true },
+ { id: "cloud", transport: cloud },
+ ]);
+ await assert.rejects(relay.connect(), /local: local offline/);
+ assert.equal(local.closed, true);
+ assert.equal(cloud.closed, true);
+});
diff --git a/aether-vscodex/test/extension-i18n-build.test.js b/aether-vscodex/test/extension-i18n-build.test.js
new file mode 100644
index 000000000..ff4969dbf
--- /dev/null
+++ b/aether-vscodex/test/extension-i18n-build.test.js
@@ -0,0 +1,34 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+
+test("VS Code runtime strings have English and Simplified Chinese bundles", () => {
+ const extensionRoot = path.join(__dirname, "..", "vscode-extension");
+ const source = fs.readFileSync(path.join(extensionRoot, "src", "extension.ts"), "utf8");
+ const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8"));
+ const english = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.json"), "utf8"));
+ const chinese = JSON.parse(fs.readFileSync(path.join(extensionRoot, "l10n", "bundle.l10n.zh-cn.json"), "utf8"));
+ const keys = [...source.matchAll(/(? match[1]);
+
+ assert.equal(manifest.l10n, "./l10n");
+ assert.ok(keys.length > 20, "expected runtime-localized extension strings");
+ for (const key of new Set(keys)) {
+ assert.equal(english[key], key, `missing English source string: ${key}`);
+ assert.equal(typeof chinese[key], "string", `missing zh-CN translation: ${key}`);
+ assert.ok(chinese[key].length > 0, `empty zh-CN translation: ${key}`);
+ }
+});
+
+test("production copy scripts require the Vue build instead of silently falling back", () => {
+ const projectRoot = path.join(__dirname, "..");
+ const extensionSync = fs.readFileSync(path.join(projectRoot, "vscode-extension", "scripts", "sync-local-relay.cjs"), "utf8");
+ const aetherSync = fs.readFileSync(path.join(projectRoot, "..", "frontend", "scripts", "sync-vscodex.mjs"), "utf8");
+ const extensionManifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "vscode-extension", "package.json"), "utf8"));
+
+ assert.match(extensionManifest.scripts["vscode:prepublish"], /build:web/);
+ assert.doesNotMatch(extensionSync, /projectRoot,\s*"public"/);
+ assert.doesNotMatch(aetherSync, /moduleRoot,\s*'public'/);
+});
diff --git a/aether-vscodex/test/local-relay.test.js b/aether-vscodex/test/local-relay.test.js
new file mode 100644
index 000000000..58ef1a7c4
--- /dev/null
+++ b/aether-vscodex/test/local-relay.test.js
@@ -0,0 +1,120 @@
+const assert = require("node:assert/strict");
+const http = require("node:http");
+const path = require("node:path");
+const test = require("node:test");
+
+const {
+ LocalRelayController,
+ localRelayTarget,
+ relayHealthAvailable,
+} = require("../vscode-extension/dist/localRelay.js");
+
+test("local relay target accepts only loopback ws URLs", () => {
+ assert.deepEqual(localRelayTarget("ws://localhost:8898/v1/connect"), {
+ host: "127.0.0.1",
+ port: 8898,
+ healthUrl: "http://127.0.0.1:8898/api/health",
+ webUrl: "http://127.0.0.1:8898/",
+ });
+ assert.equal(localRelayTarget("wss://127.0.0.1:8898/v1/connect"), undefined);
+ assert.equal(localRelayTarget("ws://192.168.1.10:8898/v1/connect"), undefined);
+ assert.equal(localRelayTarget("not a url"), undefined);
+});
+
+test("local relay health probe recognizes a responding HTTP service", async (t) => {
+ const server = http.createServer((request, response) => {
+ if (request.url === "/api/health") {
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
+ } else if (request.url === "/aborted") {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.write('{"ok":');
+ response.destroy();
+ } else if (request.url === "/drip") {
+ response.writeHead(200, { "content-type": "application/json" });
+ const interval = setInterval(() => response.write(" "), 10);
+ response.on("close", () => clearInterval(interval));
+ } else {
+ response.writeHead(404).end();
+ }
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ t.after(() => new Promise((resolve) => server.close(resolve)));
+ const address = server.address();
+ assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/api/health`), true);
+ assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/missing`), false);
+ assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/aborted`, 100), false);
+ const startedAt = Date.now();
+ assert.equal(await relayHealthAvailable(`http://127.0.0.1:${address.port}/drip`, 50), false);
+ assert.ok(Date.now() - startedAt < 500);
+});
+
+test("local relay controller starts and stops a bundled loopback relay", async () => {
+ let starts = 0;
+ let stops = 0;
+ class FakeRelay {
+ async start() { starts += 1; return { host: "127.0.0.1", port: 65534 }; }
+ async stop() { stops += 1; }
+ }
+ const controller = new LocalRelayController({
+ extensionPath: path.resolve(__dirname, "../vscode-extension"),
+ probeTimeoutMs: 20,
+ loadRelayModule: () => ({ CodexRelay: FakeRelay }),
+ });
+ assert.equal(await controller.ensureRunning("ws://127.0.0.1:65534/v1/connect"), true);
+ assert.equal(starts, 1);
+ await controller.stop();
+ assert.equal(stops, 1);
+});
+
+test("local relay controller does not leak a relay when stopped during startup", async () => {
+ let releaseStart;
+ const startGate = new Promise((resolve) => { releaseStart = resolve; });
+ let startEntered;
+ const entered = new Promise((resolve) => { startEntered = resolve; });
+ let stops = 0;
+ class SlowRelay {
+ async start() {
+ startEntered();
+ await startGate;
+ return { host: "127.0.0.1", port: 65533 };
+ }
+ async stop() { stops += 1; }
+ }
+ const controller = new LocalRelayController({
+ extensionPath: path.resolve(__dirname, "../vscode-extension"),
+ probeTimeoutMs: 20,
+ loadRelayModule: () => ({ CodexRelay: SlowRelay }),
+ });
+ const starting = controller.ensureRunning("ws://127.0.0.1:65533/v1/connect");
+ await entered;
+ const stopping = controller.stop();
+ releaseStart();
+ await Promise.all([starting, stopping]);
+ assert.equal(stops, 1);
+});
+
+test("local relay controller does not start after stop wins an in-flight health probe", async () => {
+ let resolveProbe;
+ const probe = new Promise((resolve) => { resolveProbe = resolve; });
+ let probeEntered;
+ const entered = new Promise((resolve) => { probeEntered = resolve; });
+ let starts = 0;
+ class FakeRelay {
+ async start() { starts += 1; return { host: "127.0.0.1", port: 65532 }; }
+ async stop() {}
+ }
+ const controller = new LocalRelayController({
+ extensionPath: path.resolve(__dirname, "../vscode-extension"),
+ loadRelayModule: () => ({ CodexRelay: FakeRelay }),
+ probeRelayHealth: async () => {
+ probeEntered();
+ return probe;
+ },
+ });
+ const ensuring = controller.ensureRunning("ws://127.0.0.1:65532/v1/connect");
+ await entered;
+ await controller.stop();
+ resolveProbe(false);
+ assert.equal(await ensuring, false);
+ assert.equal(starts, 0);
+});
diff --git a/aether-vscodex/test/public-embed-i18n.test.js b/aether-vscodex/test/public-embed-i18n.test.js
new file mode 100644
index 000000000..6cffda015
--- /dev/null
+++ b/aether-vscodex/test/public-embed-i18n.test.js
@@ -0,0 +1,194 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const test = require("node:test");
+
+const { createAetherEmbedBridge, isAetherEmbed } = require("../public/embed-bridge.js");
+const i18n = require("../public/i18n.js");
+
+function embeddedWindow() {
+ const listeners = new Map();
+ const posts = [];
+ const parent = { postMessage: (message, origin) => posts.push({ message, origin }) };
+ const bodyClasses = new Set();
+ const documentElement = { dataset: {}, style: {} };
+ const windowLike = {
+ location: { search: "?embed=aether", origin: "https://aether.example" },
+ parent,
+ document: {
+ body: { classList: { add: (value) => bodyClasses.add(value) } },
+ documentElement,
+ },
+ addEventListener: (name, listener) => listeners.set(name, listener),
+ removeEventListener: (name, listener) => {
+ if (listeners.get(name) === listener) listeners.delete(name);
+ },
+ };
+ return { bodyClasses, documentElement, listeners, parent, posts, windowLike };
+}
+
+test("Aether embed mode is opt-in and announces readiness only to the same-origin parent", () => {
+ assert.equal(isAetherEmbed({ search: "" }), false);
+ assert.equal(isAetherEmbed({ search: "?embed=other" }), false);
+ assert.equal(isAetherEmbed({ search: "?embed=aether" }), true);
+
+ const fixture = embeddedWindow();
+ const bridge = createAetherEmbedBridge(fixture.windowLike);
+ assert.equal(bridge.active, true);
+ bridge.start();
+ assert.equal(fixture.bodyClasses.has("embed-aether"), true);
+ assert.deepEqual(fixture.posts, [{
+ message: { v: 1, type: "aether-vscodex/ready" },
+ origin: "https://aether.example",
+ }]);
+});
+
+test("Aether embed bridge rejects cross-origin and non-parent messages and buffers an early connect", () => {
+ const fixture = embeddedWindow();
+ const bridge = createAetherEmbedBridge(fixture.windowLike);
+ bridge.start();
+ const dispatch = fixture.listeners.get("message");
+ const connect = {
+ v: 1,
+ type: "aether-vscodex/connect",
+ ticket: "one-time-ticket",
+ wsUrl: "/api/vscodex/ws",
+ locale: "en-US",
+ theme: "dark",
+ };
+
+ dispatch({ origin: "https://attacker.example", source: fixture.parent, data: connect });
+ dispatch({ origin: "https://aether.example", source: {}, data: connect });
+ let received = null;
+ bridge.on("connect", (message) => { received = message; });
+ assert.equal(received, null);
+
+ dispatch({ origin: "https://aether.example", source: fixture.parent, data: connect });
+ assert.equal(received.ticket, "one-time-ticket");
+ assert.equal(fixture.documentElement.dataset.theme, "dark");
+
+ const second = embeddedWindow();
+ const bufferedBridge = createAetherEmbedBridge(second.windowLike);
+ bufferedBridge.start();
+ second.listeners.get("message")({ origin: "https://aether.example", source: second.parent, data: connect });
+ let buffered = null;
+ bufferedBridge.on("connect", (message) => { buffered = message; });
+ assert.equal(buffered.ticket, "one-time-ticket");
+});
+
+test("bridge ticket requests never place the ticket in a URL", () => {
+ const fixture = embeddedWindow();
+ const bridge = createAetherEmbedBridge(fixture.windowLike);
+ bridge.start();
+ bridge.requestTicket({ reason: "disconnected", deviceId: "device-1" });
+ assert.deepEqual(fixture.posts.at(-1), {
+ message: {
+ v: 1,
+ type: "aether-vscodex/request-ticket",
+ reason: "disconnected",
+ deviceId: "device-1",
+ },
+ origin: "https://aether.example",
+ });
+});
+
+test("locale dictionary covers static shell and core dynamic status text", () => {
+ assert.equal(i18n.translate("设置", "en-US"), "Settings");
+ assert.equal(i18n.translate("中文", "en-US"), "Chinese");
+ assert.equal(i18n.translate("正在思考", "en-US"), "Thinking");
+ assert.equal(i18n.translate("已读取这些内容 · 4 个文件", "en-US"), "Read these items · 4 files");
+ assert.equal(i18n.translate("用时 3分45秒", "en-US"), "Worked for 3m45s");
+ assert.equal(i18n.translate("修改权限,当前为需要时询问", "en-US"), "Change permissions. Current: Ask when needed");
+ assert.equal(i18n.translate("模型设置更新失败:timeout", "en-US"), "Unable to update model settings: timeout");
+ assert.equal(i18n.translate("请求 #17 已发送,等待 VS Code 主机确认", "en-US"), "Request #17 sent; waiting for the VS Code host");
+ assert.equal(i18n.translate("无法读取 notes.md", "en-US"), "Unable to read notes.md");
+ assert.equal(i18n.translate("命令: timed out", "en-US"), "Command: timed out");
+ assert.equal(i18n.translate("命令: timed out(执行状态未知,请等待主机恢复)", "en-US"), "Command: timed out (execution status unknown; wait for the host to recover)");
+ assert.equal(i18n.translate("子代理 失败", "en-US"), "Subagent failed");
+ assert.equal(i18n.translate("已在 2秒 内运行 echo hi", "en-US"), "Ran echo hi in 2s");
+ assert.equal(i18n.translate("命令运行失败 · echo hi · 2秒", "en-US"), "Command failed · echo hi · 2s");
+ assert.equal(i18n.translate("命令运行失败 · echo hi", "en-US"), "Command failed · echo hi");
+ assert.equal(i18n.translate("已停止 echo hi · 2秒", "en-US"), "Stopped echo hi · 2s");
+ assert.equal(i18n.translate("文件变更 · 失败", "en-US"), "File changes · Failed");
+ assert.equal(i18n.translate("文件变更 · 已中断", "en-US"), "File changes · Interrupted");
+ assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
+ assert.equal(i18n.translate("命令 · 设置", "en-US"), "Command · 设置");
+ assert.equal(i18n.translate("正在读取 设置", "en-US"), "Reading 设置");
+ assert.equal(i18n.translate("已在 2秒 内运行 设置", "en-US"), "Ran 设置 in 2s");
+ assert.equal(i18n.translate("正在切换到「设置」…", "en-US"), "Switching to “设置”...");
+ assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
+ assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
+ assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
+ assert.equal(i18n.translate("已工具 · 2秒", "en-US"), "Tool completed · 2s");
+ assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
+ assert.equal(i18n.translate("编辑了文件", "en-US"), "Edited files");
+ assert.equal(i18n.translate("编辑了文件 · 2秒", "en-US"), "Edited files · 2s");
+ assert.equal(i18n.translate("已完成计划", "en-US"), "Completed plan");
+ assert.equal(i18n.translate("已完成计划 · 2秒", "en-US"), "Completed plan · 2s");
+ assert.equal(i18n.translate("…(文件已截断)", "en-US"), "... (file truncated)");
+ assert.equal(i18n.translate("事件窗口已过期,请以当前快照为准", "en-US"), "The event window expired; the current snapshot is authoritative");
+ assert.equal(i18n.translate("控制模式", "en-US"), "Control mode");
+ assert.equal(i18n.translate("同步模式跟随 VS Code 当前会话", "en-US"), "Sync mode follows the current VS Code conversation");
+ assert.equal(i18n.translate("异步模式可独立管理会话", "en-US"), "Async mode manages conversations independently");
+ assert.equal(i18n.translate("当前任务或请求完成后才能切换控制模式", "en-US"), "The control mode can be changed after the current task or request finishes");
+ assert.equal(i18n.translate("Settings", "zh-CN"), "设置");
+ assert.equal(i18n.normalizeLocale("zh-Hans"), "zh-CN");
+ assert.equal(i18n.normalizeLocale("en-GB"), "en-US");
+});
+
+test("renderer-owned dynamic labels have English fallbacks without translating host values", () => {
+ assert.equal(i18n.translate("命令 · echo hi", "en-US"), "Command · echo hi");
+ assert.equal(i18n.translate("你停止了工作", "en-US"), "You stopped working");
+ assert.equal(i18n.translate("工具失败", "en-US"), "Tool failed");
+ assert.equal(i18n.translate("正在搜索", "en-US"), "Searching");
+ assert.equal(i18n.translate("当前模型 5.6 Sol 标准,切换模型", "en-US"), "Current model: 5.6 Sol Medium. Change model");
+
+ const app = fs.readFileSync(path.join(__dirname, "..", "public", "app.js"), "utf8");
+ // Command/path/title values are appended after a locale-specific prefix;
+ // they are never passed through the translator as a whole.
+ assert.match(app, /uiWithRaw\("正在运行 ", "Running ",/);
+ assert.match(app, /uiWithRaw\("已读取 ", "Read ",/);
+ assert.match(app, /uiLocale\(\) === "en-US" \? `Switching to/);
+});
+
+test("public shell uses relative assets and embedded startup skips the health probe", () => {
+ const publicRoot = path.join(__dirname, "..", "public");
+ const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
+ const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
+ assert.match(html, /href="\.\/style\.css"/);
+ assert.match(html, /src="\.\/embed-bridge\.js"/);
+ assert.match(html, /src="\.\/i18n\.js"/);
+ assert.match(html, /src="\.\/app\.js"/);
+ assert.match(app, /if \(embeddedInAether\)[\s\S]+else \{[\s\S]+fetch\("\.\/api\/health"/);
+ assert.doesNotMatch(app, /ticket=.*state\.embedTicket/);
+ assert.match(app, /empty\.textContent = t\(activity\.status === "inProgress" \? "正在读取文件" : "读取完成"\)/);
+ assert.match(app, /outputContent\.textContent = t\("无输出"\)/);
+ assert.match(app, /button\.title = t\(title\)/);
+ assert.match(app, /activity\.action === "spawnAgent" \? t\("启动子代理"\)/);
+ assert.match(app, /return t\("需要远程确认或输入"\)/);
+ assert.match(app, /questionPrompt === undefined \|\| questionPrompt === null \? t\("请输入"\)/);
+ assert.match(app, /checkbox\.setAttribute\("aria-label", t\(checkbox\.checked \? "已完成" : "未完成"\)\)/);
+ assert.match(app, /window\.addEventListener\("aether-vscodex:locale", \(\) => \{[\s\S]+state\.activities\.values\(\)[\s\S]+renderRequests\(\)/);
+});
+
+test("control mode is snapshot-authoritative and gates independent session actions", () => {
+ const publicRoot = path.join(__dirname, "..", "public");
+ const html = fs.readFileSync(path.join(publicRoot, "index.html"), "utf8");
+ const app = fs.readFileSync(path.join(publicRoot, "app.js"), "utf8");
+
+ assert.match(html, /id="controlModeSwitch"[\s\S]+data-control-mode="sync"[\s\S]+data-control-mode="async"/);
+ assert.match(app, /command\("control\/mode\/set", \{ mode \}\)/);
+ assert.match(app, /applyControlModeSnapshot\(payload\.metadata\)/);
+ assert.match(app, /const controlMetadata = \{[\s\S]+snapshot\.metadata[\s\S]+appState\.sessionMetadata[\s\S]+applyControlModeSnapshot\(controlMetadata\)/);
+ assert.match(app, /sessionList: source\.sessionList === true/);
+ assert.match(app, /Boolean\(state\.sessionListCommandId\)/);
+ assert.match(app, /mode_switch_pending.*return "正在切换控制模式"/);
+ assert.match(app, /mode_busy\|cannot switch control mode.*return "当前任务或请求完成后才能切换控制模式"/);
+ assert.match(app, /setConversationStatus\(sessionErrorMessage\(message, "控制模式切换失败"\), "warning"\)/);
+ assert.match(app, /if \(!sessionControlAllowed\("sessionList"\)\) return;/);
+ assert.match(app, /if \(!sessionControlAllowed\("sessionSelect"\)\)/);
+ assert.match(app, /if \(!sessionControlAllowed\("sessionCreate"\)\)/);
+ assert.match(app, /sessionPickerButton\.disabled = !listAllowed/);
+});
diff --git a/aether-vscodex/test/relay-client.test.js b/aether-vscodex/test/relay-client.test.js
new file mode 100644
index 000000000..f2f2adcbe
--- /dev/null
+++ b/aether-vscodex/test/relay-client.test.js
@@ -0,0 +1,206 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const { EventEmitter } = require("node:events");
+const test = require("node:test");
+
+const { RelayClient } = require("../vscode-extension/dist/relayClient.js");
+
+class FakeWebSocket extends EventEmitter {
+ static instances = [];
+
+ constructor(url) {
+ super();
+ this.url = url;
+ this.readyState = 0;
+ this.sent = [];
+ FakeWebSocket.instances.push(this);
+ }
+
+ open() {
+ this.readyState = 1;
+ this.emit("open");
+ }
+
+ receive(frame) {
+ this.emit("message", Buffer.from(JSON.stringify(frame)));
+ }
+
+ send(data) {
+ this.sent.push(JSON.parse(data));
+ }
+
+ close() {
+ if (this.readyState === 3) return;
+ this.readyState = 3;
+ this.emit("close");
+ }
+}
+
+test("RelayClient queues application frames until auth.ok on initial connect and reconnect", async (t) => {
+ FakeWebSocket.instances.length = 0;
+ const client = new RelayClient({
+ url: "ws://relay.invalid/v1/connect",
+ accessToken: "host-token",
+ reconnect: false,
+ webSocket: FakeWebSocket,
+ });
+ t.after(() => client.close());
+
+ const firstConnect = client.connect();
+ const first = FakeWebSocket.instances[0];
+ first.open();
+ assert.deepEqual(first.sent.map((frame) => frame.kind), ["hello", "auth"]);
+
+ client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-1", sessionId: "session-1", payload: { text: "queued" } });
+ assert.equal(first.sent.length, 2, "application event must not be sent before authentication");
+ first.receive({ type: "auth.ok", role: "host", clientType: "host" });
+ await firstConnect;
+ assert.equal(first.sent.length, 3);
+ assert.equal(first.sent[2].id, "event-1");
+
+ first.close();
+ const secondConnect = client.connect();
+ const second = FakeWebSocket.instances[1];
+ second.open();
+ assert.deepEqual(second.sent.map((frame) => frame.kind), ["hello", "auth"]);
+
+ client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-2", sessionId: "session-1", payload: { text: "queued during reconnect" } });
+ assert.equal(second.sent.length, 2, "reconnect window must remain auth-gated");
+ second.receive({ type: "auth.ok", role: "host", clientType: "host" });
+ await secondConnect;
+ assert.equal(second.sent.length, 3);
+ assert.equal(second.sent[2].id, "event-2");
+});
+
+test("RelayClient coalesces queued transcript projections within a byte budget", async (t) => {
+ FakeWebSocket.instances.length = 0;
+ const client = new RelayClient({
+ url: "ws://relay.invalid/v1/connect",
+ accessToken: "host-token",
+ reconnect: false,
+ maxFrameBytes: 4_096,
+ maxQueuedBytes: 4_096,
+ webSocket: FakeWebSocket,
+ });
+ t.after(() => client.close());
+
+ const connecting = client.connect();
+ const socket = FakeWebSocket.instances[0];
+ socket.open();
+ client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(700) } });
+ client.send({ v: 1, kind: "event", type: "output.snapshot", id: "old-projection", sessionId: "session-1", payload: { text: "x".repeat(1_200) } });
+ client.send({ v: 1, kind: "event", type: "output.chunk", id: "new-projection", sessionId: "session-1", payload: { text: "y".repeat(1_200) } });
+ client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(700) } });
+
+ assert.ok(client.queueBytes <= 4_096);
+ socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
+ await connecting;
+ const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
+ assert.deepEqual(queuedIds, ["approval", "new-projection", "command"]);
+});
+
+test("RelayClient evicts reconstructible projections before queued control events", async (t) => {
+ FakeWebSocket.instances.length = 0;
+ const client = new RelayClient({
+ url: "ws://relay.invalid/v1/connect",
+ accessToken: "host-token",
+ reconnect: false,
+ maxFrameBytes: 4_096,
+ maxQueuedBytes: 2_500,
+ webSocket: FakeWebSocket,
+ });
+ t.after(() => client.close());
+
+ const connecting = client.connect();
+ const socket = FakeWebSocket.instances[0];
+ socket.open();
+ client.send({ v: 1, kind: "event", type: "approval.requested", id: "approval", sessionId: "session-1", payload: { text: "a".repeat(850) } });
+ client.send({ v: 1, kind: "event", type: "output.chunk", id: "projection", sessionId: "session-1", payload: { text: "x".repeat(900) } });
+ client.send({ v: 1, kind: "event", type: "command.result", id: "command", sessionId: "session-1", payload: { text: "c".repeat(850) } });
+
+ assert.ok(client.queueBytes <= 2_500);
+ socket.receive({ type: "auth.ok", role: "host", clientType: "host" });
+ await connecting;
+ const queuedIds = socket.sent.slice(2).map((frame) => frame.id);
+ assert.deepEqual(queuedIds, ["approval", "command"]);
+});
+
+test("RelayClient supports a tokenless local handshake", async (t) => {
+ FakeWebSocket.instances.length = 0;
+ const client = new RelayClient({
+ url: "ws://127.0.0.1:8787/v1/connect",
+ reconnect: false,
+ webSocket: FakeWebSocket,
+ });
+ t.after(() => client.close());
+
+ const connecting = client.connect();
+ const socket = FakeWebSocket.instances[0];
+ socket.open();
+ assert.deepEqual(socket.sent.map((frame) => frame.kind), ["hello"]);
+ socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
+ await connecting;
+
+ client.send({ v: 1, kind: "event", type: "connection.opened", id: "event-local", sessionId: "session-local", payload: {} });
+ assert.equal(socket.sent.length, 2);
+ assert.equal(socket.sent[1].type, "connection.opened");
+});
+
+test("RelayClient accepts structured history snapshots larger than the old 256 KiB limit", async (t) => {
+ FakeWebSocket.instances.length = 0;
+ const client = new RelayClient({
+ url: "ws://127.0.0.1:8787/v1/connect",
+ reconnect: false,
+ webSocket: FakeWebSocket,
+ });
+ t.after(() => client.close());
+
+ const connecting = client.connect();
+ const socket = FakeWebSocket.instances[0];
+ socket.open();
+ socket.receive({ type: "auth.ok", role: "host", clientType: "host", authRequired: false });
+ await connecting;
+
+ const historyText = "x".repeat(512 * 1024);
+ assert.doesNotThrow(() => client.send({
+ v: 1,
+ kind: "event",
+ type: "session.snapshot",
+ id: "large-history-snapshot",
+ sessionId: "session-local",
+ payload: { threadId: "large-thread", messages: [{ kind: "assistant", text: historyText }] },
+ }));
+ assert.equal(socket.sent.at(-1).payload.messages[0].text.length, historyText.length);
+});
+
+test("RelayClient ignores late events from a replaced socket", async (t) => {
+ FakeWebSocket.instances.length = 0;
+ const client = new RelayClient({
+ url: "ws://relay.invalid/v1/connect",
+ accessToken: "host-token",
+ reconnect: false,
+ webSocket: FakeWebSocket,
+ });
+ t.after(() => client.close());
+
+ const firstConnect = client.connect();
+ const first = FakeWebSocket.instances[0];
+ first.open();
+ client.close();
+
+ const secondConnect = client.connect();
+ const second = FakeWebSocket.instances[1];
+ second.open();
+
+ // Simulate a delayed event from the old socket after the replacement.
+ first.open();
+ first.receive({ type: "auth.ok", role: "host", clientType: "host" });
+ assert.equal(second.sent.length, 2, "late auth must not authenticate or flush the new socket");
+
+ client.send({ v: 1, kind: "event", type: "output.chunk", id: "event-after-replace", sessionId: "session-1", payload: { text: "queued" } });
+ second.receive({ type: "auth.ok", role: "host", clientType: "host" });
+ await secondConnect;
+ assert.equal(second.sent[2].id, "event-after-replace");
+ await assert.rejects(firstConnect);
+});
diff --git a/aether-vscodex/test/relay.test.js b/aether-vscodex/test/relay.test.js
new file mode 100644
index 000000000..c248ef9ae
--- /dev/null
+++ b/aether-vscodex/test/relay.test.js
@@ -0,0 +1,1610 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const http = require("node:http");
+const path = require("node:path");
+const test = require("node:test");
+const { WebSocket } = require("ws");
+const { CodexRelay } = require("../relay/server.js");
+
+const fakeServer = path.join(__dirname, "..", "fixtures", "fake-app-server.cjs");
+
+function waitFor(predicate, timeout = 5_000) {
+ const started = Date.now();
+ return new Promise((resolve, reject) => {
+ const tick = () => {
+ try {
+ const result = predicate();
+ if (result) return resolve(result);
+ } catch (error) { return reject(error); }
+ if (Date.now() - started > timeout) return reject(new Error("timed out waiting for condition"));
+ setTimeout(tick, 20);
+ };
+ tick();
+ });
+}
+
+function request(base, token, pathname, options = {}) {
+ return fetch(`${base}${pathname}`, {
+ ...options,
+ headers: { Authorization: `Bearer ${token}`, ...(options.headers || {}) },
+ });
+}
+
+function connectWs(base, token) {
+ const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws");
+ const messages = [];
+ const waiters = [];
+ ws.on("message", (data) => {
+ const message = JSON.parse(data.toString());
+ messages.push(message);
+ for (let index = waiters.length - 1; index >= 0; index -= 1) {
+ if (waiters[index].predicate(message)) {
+ const waiter = waiters.splice(index, 1)[0];
+ waiter.resolve(message);
+ }
+ }
+ });
+ const wait = (predicate, timeout = 5_000) => new Promise((resolve, reject) => {
+ const existing = messages.find(predicate);
+ if (existing) return resolve(existing);
+ const timer = setTimeout(() => {
+ const index = waiters.findIndex((waiter) => waiter.resolve === resolve);
+ if (index >= 0) waiters.splice(index, 1);
+ reject(new Error("timed out waiting for websocket message"));
+ }, timeout);
+ waiters.push({ predicate, resolve: (message) => { clearTimeout(timer); resolve(message); } });
+ });
+ return new Promise((resolve, reject) => {
+ ws.once("open", () => {
+ ws.send(JSON.stringify({ type: "auth", token }));
+ wait((message) => message.type === "auth.ok").then(() => {
+ ws.send(JSON.stringify({ type: "subscribe", fromSeq: 0 }));
+ resolve({ ws, wait, messages });
+ }, reject);
+ });
+ ws.once("error", reject);
+ });
+}
+
+function connectBrowserHello(base, token) {
+ const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws");
+ const messages = [];
+ const waiters = [];
+ ws.on("message", (data) => {
+ const message = JSON.parse(data.toString());
+ messages.push(message);
+ for (let index = waiters.length - 1; index >= 0; index -= 1) {
+ if (waiters[index].predicate(message)) {
+ const waiter = waiters.splice(index, 1)[0];
+ waiter.resolve(message);
+ }
+ }
+ });
+ const wait = (predicate, timeout = 5_000) => new Promise((resolve, reject) => {
+ const existing = messages.find(predicate);
+ if (existing) return resolve(existing);
+ const timer = setTimeout(() => {
+ const index = waiters.findIndex((waiter) => waiter.resolve === resolve);
+ if (index >= 0) waiters.splice(index, 1);
+ reject(new Error("timed out waiting for websocket message"));
+ }, timeout);
+ waiters.push({ predicate, resolve: (message) => { clearTimeout(timer); resolve(message); } });
+ });
+ return new Promise((resolve, reject) => {
+ ws.once("open", () => {
+ ws.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 }));
+ if (token !== undefined) ws.send(JSON.stringify({ type: "auth", token }));
+ wait((message) => message.type === "auth.ok").then(() => resolve({ ws, wait, messages }), reject);
+ });
+ ws.once("error", reject);
+ });
+}
+
+function connectHost(base, token, sessionId = "host-session") {
+ const ws = new WebSocket(base.replace(/^http/, "ws") + "/v1/connect");
+ const messages = [];
+ const waiters = [];
+ ws.on("message", (data) => {
+ const message = JSON.parse(data.toString());
+ messages.push(message);
+ for (let index = waiters.length - 1; index >= 0; index -= 1) {
+ if (waiters[index].predicate(message)) {
+ const waiter = waiters.splice(index, 1)[0];
+ waiter.resolve(message);
+ }
+ }
+ });
+ const wait = (predicate, timeout = 5_000) => new Promise((resolve, reject) => {
+ const existing = messages.find(predicate);
+ if (existing) return resolve(existing);
+ const timer = setTimeout(() => {
+ const index = waiters.findIndex((waiter) => waiter.resolve === resolve);
+ if (index >= 0) waiters.splice(index, 1);
+ reject(new Error("timed out waiting for host websocket message"));
+ }, timeout);
+ waiters.push({ predicate, resolve: (message) => { clearTimeout(timer); resolve(message); } });
+ });
+ return new Promise((resolve, reject) => {
+ ws.once("open", () => {
+ ws.send(JSON.stringify({ v: 1, kind: "hello", clientType: "host", protocol: 1, sessionId }));
+ ws.send(JSON.stringify({ v: 1, kind: "auth", accessToken: token }));
+ wait((message) => message.type === "auth.ok" && message.clientType === "host").then(() => {
+ resolve({ ws, wait, messages, sessionId });
+ }, reject);
+ });
+ ws.once("error", reject);
+ });
+}
+
+test("loopback relay defaults to tokenless browser and VS Code host connections", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ });
+ assert.equal(relay.authRequired, false);
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const health = await fetch(`${base}/api/health`);
+ assert.equal(health.status, 200);
+ assert.equal((await health.json()).authRequired, false);
+
+ const state = await fetch(`${base}/api/state`);
+ assert.equal(state.status, 200);
+ assert.equal((await state.json()).role, "operator");
+
+ const crossOriginWrite = await fetch(`${base}/api/command`, {
+ method: "POST",
+ headers: { Origin: "https://untrusted.example", "Content-Type": "application/json" },
+ body: JSON.stringify({ commandId: "cross-origin", method: "turn/start", params: {} }),
+ });
+ assert.equal(crossOriginWrite.status, 403);
+
+ const reboundHost = await new Promise((resolve, reject) => {
+ const request = http.request({
+ host: "127.0.0.1",
+ port: address.port,
+ path: "/api/health",
+ headers: { Host: `rebound.example:${address.port}` },
+ }, (response) => {
+ response.resume();
+ response.once("end", () => resolve(response));
+ });
+ request.once("error", reject);
+ request.end();
+ });
+ assert.equal(reboundHost.statusCode, 403);
+
+ const deceptiveHost = await new Promise((resolve, reject) => {
+ const request = http.request({
+ host: "127.0.0.1",
+ port: address.port,
+ path: "/api/health",
+ headers: { Host: `127.evil:${address.port}` },
+ }, (response) => {
+ response.resume();
+ response.once("end", () => resolve(response));
+ });
+ request.once("error", reject);
+ request.end();
+ });
+ assert.equal(deceptiveHost.statusCode, 403);
+
+ const host = await connectHost(base, undefined, "tokenless-host");
+ t.after(() => host.ws.close());
+ const browser = await connectWs(base, undefined);
+ t.after(() => browser.ws.close());
+ const browserHello = await connectBrowserHello(base);
+ t.after(() => browserHello.ws.close());
+ const browserStaleToken = await connectBrowserHello(base, "stale-local-token");
+ t.after(() => browserStaleToken.ws.close());
+ assert.equal(host.messages.find((message) => message.type === "auth.ok")?.role, "host");
+ assert.equal(browser.messages.find((message) => message.type === "auth.ok")?.role, "operator");
+ assert.equal(browserHello.messages.find((message) => message.type === "auth.ok")?.authRequired, false);
+ assert.equal(browserStaleToken.messages.some((message) => message.type === "error"), false);
+});
+
+test("loopback relay hydrates attached-session snapshots larger than 256 KiB", async (t) => {
+ const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const host = await connectHost(base, undefined, "large-snapshot-host");
+ const browser = await connectWs(base, undefined);
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+
+ const historyText = "x".repeat(512 * 1024);
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "session.snapshot",
+ id: "large-snapshot-event",
+ sessionId: host.sessionId,
+ seq: 1,
+ ts: new Date().toISOString(),
+ payload: {
+ threadId: "large-thread",
+ state: "idle",
+ outputTail: historyText.slice(-32_000),
+ messages: [{ kind: "assistant", text: historyText }],
+ metadata: { title: "Large attached session", historyComplete: false },
+ },
+ }));
+
+ const snapshot = await browser.wait((message) => message.kind === "event"
+ && message.type === "session.snapshot"
+ && message.payload?.sourceSeq === 1);
+ assert.equal(snapshot.payload.messages[0].text.length, historyText.length);
+ assert.equal(relay.state.messages[0].text.length, historyText.length);
+ assert.equal(relay.state.sessionMetadata.title, "Large attached session");
+ assert.equal(relay.state.sessionMetadata.historyComplete, false);
+ const replayed = relay.events.find((event) => event.type === "session.snapshot" && event.payload?.sourceSeq === 1);
+ assert.equal(replayed.payload.messages, undefined);
+ assert.equal(replayed.payload.projectionInControlSnapshot, true);
+ const controlSnapshot = relay.snapshot();
+ assert.equal(controlSnapshot.state.messages, undefined);
+ assert.equal(controlSnapshot.state.outputTail, undefined);
+ assert.equal(controlSnapshot.state.subagents, undefined);
+ assert.equal(controlSnapshot.state.sessionMetadata, undefined);
+ assert.equal(controlSnapshot.metadata.historyComplete, false);
+ const serializedControl = JSON.stringify(controlSnapshot);
+ assert.ok(Buffer.byteLength(serializedControl) < historyText.length + 100_000, "history must be serialized only once");
+});
+
+test("late browser control snapshot preserves the attach waiting state", async (t) => {
+ const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const host = await connectHost(base, undefined, "waiting-session-host");
+ t.after(() => host.ws.close());
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "session.snapshot",
+ id: "waiting-session-snapshot",
+ sessionId: host.sessionId,
+ seq: 1,
+ ts: new Date().toISOString(),
+ payload: {
+ threadId: null,
+ state: "waiting_for_host",
+ metadata: { waitingForSession: true, attachReady: false },
+ },
+ }));
+ await host.wait((message) => message.kind === "ack" && message.seq === 1);
+
+ const browser = await connectWs(base, undefined);
+ t.after(() => browser.ws.close());
+ const control = await browser.wait((message) => message.type === "session.snapshot"
+ && message.snapshot && typeof message.snapshot === "object");
+ assert.equal(control.snapshot.state.activeThreadId, null);
+ assert.equal(control.snapshot.metadata.waitingForSession, true);
+ assert.equal(control.snapshot.metadata.attachReady, false);
+});
+
+test("relay keeps live transcript events rich while bounding the replay ring", () => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ eventByteLimit: 4_096,
+ });
+ const liveClient = { id: "live", role: "operator", authenticated: true, subscribed: true, capture: [] };
+ relay.clients.add(liveClient);
+ relay.recordEvent("output.chunk", {
+ threadId: "thread-large",
+ text: "delta",
+ outputTail: "o".repeat(32_000),
+ messages: [{ text: "m".repeat(32_000) }],
+ messagesPatch: { start: 0, deleteCount: 0, messages: [{ text: "p".repeat(32_000) }] },
+ subagents: [{ output: "s".repeat(32_000) }],
+ raw: { transcript: "r".repeat(32_000) },
+ });
+
+ assert.equal(liveClient.capture[0].payload.messagesPatch.messages[0].text.length, 32_000);
+ const replayed = relay.events[0];
+ assert.equal(replayed.payload.text, "delta");
+ assert.equal(replayed.payload.messages, undefined);
+ assert.equal(replayed.payload.messagesPatch, undefined);
+ assert.equal(replayed.payload.outputTail, undefined);
+ assert.equal(replayed.payload.subagents, undefined);
+ assert.equal(replayed.payload.raw, undefined);
+ assert.equal(replayed.payload.projectionInControlSnapshot, true);
+
+ relay.clients.delete(liveClient);
+ for (let index = 0; index < 30; index += 1) {
+ relay.recordEvent("command.pending", { commandId: `command-${index}`, note: "n".repeat(400) });
+ }
+ assert.ok(relay.eventBytes <= 4_096);
+ assert.equal(relay.eventBytes, relay.eventSizes.reduce((total, size) => total + size, 0));
+ assert.ok(relay.events.length < 30, "byte budget should prune before the count limit");
+});
+
+test("relay skips oversized replay windows and sends one authoritative snapshot", () => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ eventByteLimit: 64 * 1024,
+ replayByteLimit: 400,
+ });
+ relay.state.messages = [{ kind: "assistant", text: "authoritative history" }];
+ for (let index = 0; index < 5; index += 1) {
+ relay.recordEvent("command.pending", { commandId: `command-${index}`, note: "n".repeat(180) });
+ }
+ const client = { id: "late", role: "operator", authenticated: true, subscribed: false, capture: [] };
+ relay.clients.add(client);
+ relay.subscribe(client, 0);
+
+ assert.equal(client.capture[0].type, "resync.required");
+ assert.equal(client.capture[0].reason, "replay_too_large");
+ assert.equal(client.capture.filter((message) => message.kind === "event").length, 0);
+ const snapshot = client.capture.find((message) => message.type === "session.snapshot");
+ assert.equal(snapshot.snapshot.messages[0].text, "authoritative history");
+ assert.equal(snapshot.snapshot.latestSeq, relay.nextSeq);
+});
+
+test("relay socket backpressure accounts for the serialized frame size", () => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ clientBufferedByteLimit: 2_048,
+ });
+ const accepted = {
+ readyState: WebSocket.OPEN,
+ bufferedAmount: 1_000,
+ sent: [],
+ send(value) { this.sent.push(value); },
+ close(code, reason) { this.closed = { code, reason }; },
+ };
+ relay.sendControl({ socket: accepted }, { type: "small", text: "x".repeat(500) });
+ assert.equal(accepted.sent.length, 1);
+ assert.equal(accepted.closed, undefined);
+
+ const slow = {
+ readyState: WebSocket.OPEN,
+ bufferedAmount: 1_900,
+ sent: [],
+ send(value) { this.sent.push(value); },
+ close(code, reason) { this.closed = { code, reason }; },
+ };
+ relay.sendControl({ socket: slow }, { type: "small", text: "x".repeat(500) });
+ assert.equal(slow.sent.length, 0);
+ assert.equal(slow.closed.code, 1013);
+});
+
+test("loopback relay can explicitly require tokens", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ authRequired: true,
+ operatorToken: "explicit-operator-token",
+ viewerToken: "explicit-viewer-token",
+ hostToken: "explicit-host-token",
+ });
+ assert.equal(relay.authRequired, true);
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const health = await fetch(`${base}/api/health`);
+ assert.equal((await health.json()).authRequired, true);
+ assert.equal((await fetch(`${base}/api/state`)).status, 401);
+
+ const browser = await connectWs(base, "explicit-operator-token");
+ t.after(() => browser.ws.close());
+ assert.equal(browser.messages.find((message) => message.type === "auth.ok")?.role, "operator");
+});
+
+test("non-loopback relay requires tokens by default", () => {
+ const relay = new CodexRelay({
+ host: "0.0.0.0",
+ port: 0,
+ mode: "host",
+ });
+ assert.equal(relay.authRequired, true);
+});
+
+test("deceptive numeric-looking hostnames never enable local no-auth", () => {
+ const relay = new CodexRelay({ host: "127.evil", port: 0, mode: "host" });
+ assert.equal(relay.authRequired, true);
+});
+
+test("relay defaults to host mode so bare npm start does not spawn Codex", () => {
+ const relay = new CodexRelay({ host: "127.0.0.1", port: 0, authRequired: false });
+ assert.equal(relay.mode, "host");
+ assert.equal(relay.spawnCodex, false);
+});
+
+test("thread settings validation accepts null reasoning effort", () => {
+ const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" });
+ assert.equal(relay.validateCommand("thread/settings/update", {
+ threadId: "thread-test",
+ threadSettings: { model: "gpt-5.6-sol", effort: null },
+ }), null);
+ assert.match(relay.validateCommand("thread/settings/update", {
+ threadId: "thread-test",
+ threadSettings: { effort: 3 },
+ }), /string or null/);
+});
+
+test("control mode validation only accepts sync and async", () => {
+ const relay = new CodexRelay({ host: "127.0.0.1", port: 0, mode: "host" });
+ assert.equal(relay.validateCommand("control/mode/get", {}), null);
+ assert.equal(relay.validateCommand("control/mode/set", { mode: "sync" }), null);
+ assert.equal(relay.validateCommand("control/mode/set", { mode: "async" }), null);
+ assert.match(relay.validateCommand("control/mode/set", { mode: "attach" }), /sync or async/);
+ assert.match(relay.validateCommand("control/mode/get", { mode: "sync" }), /does not accept/);
+});
+
+test("authenticated relay forwards commands, output, approval requests, and replay", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "embedded",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ codexCommand: process.execPath,
+ codexArgs: [fakeServer],
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ await waitFor(() => relay.state.initialized);
+
+ const health = await fetch(`${base}/api/health`);
+ assert.equal(health.status, 200);
+ const unauthorized = await fetch(`${base}/api/state`);
+ assert.equal(unauthorized.status, 401);
+
+ const viewerCommand = await request(base, "viewer-test-token", "/api/command", {
+ method: "POST",
+ body: JSON.stringify({ commandId: "viewer-1", method: "thread/start", params: {} }),
+ headers: { "Content-Type": "application/json" },
+ });
+ assert.equal(viewerCommand.status, 400);
+ assert.equal((await viewerCommand.json()).code, "forbidden");
+
+ const client = await connectWs(base, "operator-test-token");
+ t.after(() => client.ws.close());
+ await client.wait((message) => message.type === "session.snapshot");
+
+ client.ws.send(JSON.stringify({ type: "command", commandId: "thread-1", method: "thread/start", params: { cwd: "/tmp", sandbox: "workspace-write" } }));
+ const threadResult = await client.wait((message) => message.type === "command.result" && message.payload?.commandId === "thread-1");
+ assert.equal(threadResult.payload.ok, true);
+ const threadId = threadResult.payload.result.thread.id;
+ assert.equal(relay.state.activeThreadId, threadId);
+
+ client.ws.send(JSON.stringify({ type: "command", commandId: "turn-1", method: "turn/start", params: { threadId, input: [{ type: "text", text: "approve this", text_elements: [] }] } }));
+ await client.wait((message) => message.type === "command.result" && message.payload?.commandId === "turn-1");
+ const approval = await client.wait((message) => message.kind === "event" && message.type === "approval.requested");
+ assert.equal(approval.payload.requestId, 9001);
+ assert.equal(approval.payload.method, "item/commandExecution/requestApproval");
+
+ client.ws.send(JSON.stringify({ type: "respond", requestId: "9001", result: { decision: "accept" } }));
+ await client.wait((message) => message.kind === "event" && message.type === "server.responded");
+ await client.wait((message) => message.kind === "event" && message.type === "output.delta" && message.payload.text.includes("approval response"));
+ assert.equal(relay.pendingServerRequests.size, 0);
+
+ const events = await request(base, "viewer-test-token", "/api/events?fromSeq=0");
+ const eventBody = await events.json();
+ assert.ok(eventBody.latestSeq >= 1);
+ assert.ok(eventBody.events.some((event) => event.type === "approval.requested"));
+});
+
+test("host mode proxies browser commands and preserves one-shot approval request ids", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const host = await connectHost(base, "separate-host-token", "session-from-vscode");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+ await browser.wait((message) => message.type === "session.snapshot");
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "connection.opened",
+ id: "host-event-1",
+ sessionId: host.sessionId,
+ seq: 1,
+ ts: new Date().toISOString(),
+ payload: {},
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+ assert.equal(relay.state.initialized, true);
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "output.chunk",
+ id: "host-event-2",
+ sessionId: host.sessionId,
+ seq: 2,
+ ts: new Date().toISOString(),
+ payload: { stream: "codex", text: "output from VS Code" },
+ }));
+ const output = await browser.wait((message) => message.kind === "event" && message.type === "output.chunk");
+ assert.equal(output.payload.text, "output from VS Code");
+ const ack = await host.wait((message) => message.kind === "ack" && message.seq === 2);
+ assert.equal(ack.sessionId, host.sessionId);
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "host-thread-1",
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const hostCommand = await host.wait((message) => message.kind === "command" && message.commandId === "host-thread-1");
+ assert.equal(hostCommand.type, "thread/start");
+ assert.equal(hostCommand.payload.sandbox, "workspace-write");
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "command.accepted",
+ id: "host-result-1",
+ sessionId: host.sessionId,
+ seq: 3,
+ ts: new Date().toISOString(),
+ payload: { commandId: "host-thread-1", method: "thread/start", ok: true, result: { thread: { id: "thread-on-host" } } },
+ }));
+ const result = await browser.wait((message) => message.kind === "event" && message.type === "command.result" && message.payload.commandId === "host-thread-1");
+ assert.equal(result.payload.ok, true);
+ assert.equal(relay.state.activeThreadId, "thread-on-host");
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "approval.requested",
+ id: "host-approval-1",
+ sessionId: host.sessionId,
+ seq: 4,
+ ts: new Date().toISOString(),
+ payload: {
+ requestId: 77,
+ method: "item/commandExecution/requestApproval",
+ commandHash: "approval-hash-77",
+ params: { command: "echo approved" },
+ },
+ }));
+ const approval = await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 77);
+ assert.equal(approval.payload.params.command, "echo approved");
+ browser.ws.send(JSON.stringify({ type: "respond", requestId: "77", result: { decision: "accept" } }));
+ const responseCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond");
+ assert.equal(responseCommand.payload.requestId, 77);
+ assert.equal(responseCommand.payload.decision, "allow");
+ assert.equal(responseCommand.payload.commandHash, "approval-hash-77");
+ assert.deepEqual(responseCommand.payload.response, { decision: "accept" });
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "command.accepted",
+ id: "host-response-result-1",
+ sessionId: host.sessionId,
+ seq: 5,
+ ts: new Date().toISOString(),
+ payload: { commandId: responseCommand.commandId, method: "approval.respond", ok: true, result: { accepted: true } },
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "server.responded" && String(message.payload.requestId) === "77");
+ assert.equal(relay.pendingServerRequests.size, 0);
+
+ browser.ws.send(JSON.stringify({ type: "respond", requestId: "77", result: { decision: "accept" } }));
+ const duplicate = await browser.wait((message) => message.type === "response.rejected" && String(message.requestId) === "77");
+ assert.equal(duplicate.code, "unknown_request");
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "approval.requested",
+ id: "host-approval-2",
+ sessionId: host.sessionId,
+ seq: 6,
+ ts: new Date().toISOString(),
+ payload: { requestId: 78, method: "item/commandExecution/requestApproval", params: { command: "echo retry" } },
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 78);
+ browser.ws.send(JSON.stringify({ type: "respond", requestId: "78", result: { decision: "accept" } }));
+ const rejectedCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond" && message.payload.requestId === 78);
+ assert.equal(rejectedCommand.payload.decision, "allow");
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "command.rejected",
+ id: "host-response-rejected-1",
+ sessionId: host.sessionId,
+ seq: 7,
+ ts: new Date().toISOString(),
+ payload: { commandId: rejectedCommand.commandId, method: "approval.respond", ok: false, error: { message: "local policy denied" } },
+ }));
+ const responseRejected = await browser.wait((message) => message.type === "response.rejected" && String(message.requestId) === "78");
+ assert.equal(responseRejected.code, "host_rejected");
+ assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "78"), true);
+});
+
+test("host mode forwards session list/select and publishes the selected session", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const host = await connectHost(base, "separate-host-token", "session-picker-host");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+
+ const sendEvent = (type, seq, payload) => host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type,
+ id: `session-picker-event-${seq}`,
+ sessionId: host.sessionId,
+ seq,
+ ts: new Date().toISOString(),
+ payload,
+ }));
+
+ sendEvent("connection.opened", 1, { mode: "attach" });
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+ sendEvent("session.snapshot", 2, {
+ threadId: "thread-one",
+ activeThreadId: "thread-one",
+ title: "当前会话",
+ metadata: {
+ controlMode: "sync",
+ modeEpoch: 0,
+ capabilities: {
+ followsVscodeRoute: true,
+ sessionList: false,
+ sessionSelect: false,
+ sessionCreate: false,
+ threadSettings: true,
+ },
+ },
+ });
+ await browser.wait((message) => message.kind === "event" && message.type === "session.snapshot");
+ assert.equal(relay.snapshot().metadata.controlMode, "sync");
+ assert.equal(relay.snapshot().metadata.capabilities.sessionSelect, false);
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "session-list-1",
+ method: "session/list",
+ params: { limit: 10 },
+ }));
+ const listCommand = await host.wait((message) => message.kind === "command" && message.commandId === "session-list-1");
+ assert.equal(listCommand.type, "session/list");
+ assert.equal(listCommand.payload.limit, 10);
+ sendEvent("command.result", 3, {
+ commandId: "session-list-1",
+ method: "session/list",
+ ok: true,
+ result: {
+ activeThreadId: "thread-one",
+ sessions: [
+ { threadId: "thread-one", title: "当前会话", active: true, available: true },
+ { threadId: "thread-two", title: "另一个会话", active: false, available: true },
+ ],
+ },
+ });
+ const listResult = await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result" && message.payload?.commandId === "session-list-1");
+ assert.equal(listResult.payload.result.sessions.length, 2);
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "session-select-1",
+ method: "session/select",
+ params: { threadId: "thread-two" },
+ }));
+ const selectCommand = await host.wait((message) => message.kind === "command" && message.commandId === "session-select-1");
+ assert.equal(selectCommand.type, "session/select");
+ assert.equal(selectCommand.payload.threadId, "thread-two");
+ sendEvent("session.switching", 4, { previousThreadId: "thread-one", targetThreadId: "thread-two" });
+ sendEvent("session.snapshot", 5, { threadId: "thread-two", activeThreadId: "thread-two", title: "另一个会话" });
+ sendEvent("session.selected", 6, { threadId: "thread-two", activeThreadId: "thread-two" });
+ sendEvent("command.result", 7, {
+ commandId: "session-select-1",
+ method: "session/select",
+ ok: true,
+ result: { threadId: "thread-two", previousThreadId: "thread-one", switched: true, available: true },
+ });
+ await browser.wait((message) => message.kind === "event" && message.type === "session.switching");
+ await browser.wait((message) => message.kind === "event" && message.type === "session.selected");
+ const selectResult = await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result" && message.payload?.commandId === "session-select-1");
+ assert.equal(selectResult.payload.result.threadId, "thread-two");
+ assert.equal(relay.state.activeThreadId, "thread-two");
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "session-new-1",
+ method: "session/new",
+ params: {},
+ }));
+ const newCommand = await host.wait((message) => message.kind === "command" && message.commandId === "session-new-1");
+ assert.equal(newCommand.type, "session/new");
+ sendEvent("command.result", 8, {
+ commandId: "session-new-1",
+ method: "session/new",
+ ok: true,
+ result: { opened: true, command: "chatgpt.newCodexPanel" },
+ });
+ const newResult = await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result" && message.payload?.commandId === "session-new-1");
+ assert.equal(newResult.payload.result.command, "chatgpt.newCodexPanel");
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "mode-set-1",
+ method: "control/mode/set",
+ params: { mode: "async" },
+ }));
+ const modeCommand = await host.wait((message) => message.kind === "command" && message.commandId === "mode-set-1");
+ assert.equal(modeCommand.type, "control/mode/set");
+ assert.deepEqual(modeCommand.payload, { mode: "async" });
+ sendEvent("command.result", 9, {
+ commandId: "mode-set-1",
+ method: "control/mode/set",
+ ok: true,
+ result: { mode: "async", modeEpoch: 1 },
+ });
+ const modeResult = await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result" && message.payload?.commandId === "mode-set-1");
+ assert.equal(modeResult.payload.result.mode, "async");
+});
+
+test("host mode records normalized server.requested events for response routing", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const host = await connectHost(base, "separate-host-token", "generic-request-host");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "connection.opened",
+ id: "generic-ready",
+ sessionId: host.sessionId,
+ seq: 1,
+ ts: new Date().toISOString(),
+ payload: {},
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "server.requested",
+ id: "generic-request",
+ sessionId: host.sessionId,
+ seq: 2,
+ ts: new Date().toISOString(),
+ payload: {
+ requestId: "generic-1",
+ method: "custom/request",
+ params: { prompt: "host-only" },
+ },
+ }));
+ const requestEvent = await browser.wait((message) => message.kind === "event" && message.type === "server.requested");
+ assert.equal(requestEvent.payload.requestId, "generic-1");
+ assert.equal(relay.pendingServerRequests.get("string:generic-1")?.method, "custom/request");
+
+ browser.ws.send(JSON.stringify({ type: "respond", requestId: "generic-1", result: { accepted: true } }));
+ const rejected = await browser.wait((message) => message.type === "response.rejected" && message.requestId === "generic-1");
+ assert.equal(rejected.code, "unsupported_request");
+ assert.equal(relay.pendingServerRequests.has("string:generic-1"), true);
+});
+
+test("host mode accepts versioned command, approval, and input response frames", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const host = await connectHost(base, "separate-host-token", "versioned-host-session");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "connection.opened",
+ id: "versioned-ready",
+ sessionId: host.sessionId,
+ seq: 1,
+ ts: new Date().toISOString(),
+ payload: {},
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+
+ browser.ws.send(JSON.stringify({
+ v: 1,
+ kind: "command",
+ type: "thread.start",
+ commandId: "versioned-thread-1",
+ payload: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const threadCommand = await host.wait((message) => message.kind === "command" && message.commandId === "versioned-thread-1");
+ assert.equal(threadCommand.type, "thread/start");
+ assert.deepEqual(threadCommand.payload, { cwd: "/tmp", sandbox: "workspace-write" });
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "approval.requested",
+ id: "versioned-approval-request",
+ sessionId: host.sessionId,
+ seq: 2,
+ ts: new Date().toISOString(),
+ payload: {
+ requestId: 91,
+ method: "item/commandExecution/requestApproval",
+ commandHash: "versioned-hash-91",
+ params: { command: "echo versioned" },
+ },
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 91);
+ browser.ws.send(JSON.stringify({
+ v: 1,
+ kind: "command",
+ type: "approval.respond",
+ commandId: "browser-approval-91",
+ payload: { requestId: 91, decision: "allow", response: { decision: "accept" } },
+ }));
+ const approvalCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond" && message.payload.requestId === 91);
+ assert.equal(approvalCommand.payload.commandHash, "versioned-hash-91");
+ assert.equal(approvalCommand.payload.decision, "allow");
+ assert.deepEqual(approvalCommand.payload.response, { decision: "accept" });
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "input.requested",
+ id: "versioned-input-request",
+ sessionId: host.sessionId,
+ seq: 3,
+ ts: new Date().toISOString(),
+ payload: {
+ requestId: 92,
+ method: "item/tool/requestUserInput",
+ params: { questions: [{ id: "choice", question: "Continue?" }] },
+ },
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "input.requested" && message.payload.requestId === 92);
+ browser.ws.send(JSON.stringify({
+ type: "input.respond",
+ payload: { requestId: 92, answers: { choice: { answers: ["yes"] } } },
+ }));
+ const inputCommand = await host.wait((message) => message.kind === "command" && message.type === "server.request.respond" && message.payload.requestId === 92);
+ assert.equal(inputCommand.payload.decision, "allow");
+ assert.deepEqual(inputCommand.payload.response, { answers: { choice: { answers: ["yes"] } } });
+
+ host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "approval.requested",
+ id: "versioned-tagged-approval",
+ sessionId: host.sessionId,
+ seq: 4,
+ ts: new Date().toISOString(),
+ payload: {
+ requestId: 93,
+ method: "item/commandExecution/requestApproval",
+ params: { command: "echo amend" },
+ },
+ }));
+ await browser.wait((message) => message.kind === "event" && message.type === "approval.requested" && message.payload.requestId === 93);
+ browser.ws.send(JSON.stringify({
+ v: 1,
+ kind: "command",
+ type: "approval.respond",
+ commandId: "browser-tagged-approval-93",
+ payload: {
+ requestId: 93,
+ decision: "allow",
+ response: { decision: { acceptWithExecpolicyAmendment: { execpolicy_amendment: ["echo"] } } },
+ },
+ }));
+ const taggedCommand = await host.wait((message) => message.kind === "command" && message.type === "approval.respond" && message.payload.requestId === 93);
+ assert.equal(taggedCommand.payload.decision, "allow");
+ assert.deepEqual(taggedCommand.payload.response, {
+ decision: { acceptWithExecpolicyAmendment: { execpolicy_amendment: ["echo"] } },
+ });
+});
+
+test("keeps numeric and string host approval ids distinct end to end", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const host = await connectHost(base, "separate-host-token", "typed-id-session");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+
+ const sendHostEvent = (type, seq, payload, id) => host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type,
+ id: id || `typed-${seq}`,
+ sessionId: host.sessionId,
+ seq,
+ ts: new Date().toISOString(),
+ payload,
+ }));
+ sendHostEvent("connection.opened", 1, {});
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+
+ sendHostEvent("approval.requested", 2, {
+ requestId: 1,
+ method: "item/commandExecution/requestApproval",
+ params: { command: "echo numeric" },
+ });
+ sendHostEvent("approval.requested", 3, {
+ requestId: "1",
+ method: "item/commandExecution/requestApproval",
+ params: { command: "echo string" },
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.requested"
+ && message.payload?.requestId === 1);
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.requested"
+ && message.payload?.requestId === "1");
+ assert.equal(relay.pendingServerRequests.size, 2);
+
+ browser.ws.send(JSON.stringify({ type: "respond", requestId: 1, result: { decision: "accept" } }));
+ browser.ws.send(JSON.stringify({ type: "respond", requestId: "1", result: { decision: "accept" } }));
+ const firstCommand = await host.wait((message) => message.kind === "command"
+ && message.type === "approval.respond"
+ && message.payload?.requestId === 1);
+ const secondCommand = await host.wait((message) => message.kind === "command"
+ && message.type === "approval.respond"
+ && message.payload?.requestId === "1");
+ assert.notEqual(firstCommand.commandId, secondCommand.commandId);
+
+ sendHostEvent("command.result", 4, {
+ commandId: firstCommand.commandId,
+ method: "approval.respond",
+ ok: true,
+ result: { accepted: true },
+ });
+ sendHostEvent("command.result", 5, {
+ commandId: secondCommand.commandId,
+ method: "approval.respond",
+ ok: true,
+ result: { accepted: true },
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "server.responded"
+ && message.payload?.requestId === 1);
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "server.responded"
+ && message.payload?.requestId === "1");
+ assert.equal(relay.pendingServerRequests.size, 0);
+});
+
+test("normalizes legacy approval decisions and rejects outer/inner conflicts", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const host = await connectHost(base, "separate-host-token", "decision-schema-session");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+ const sendHostEvent = (type, seq, payload) => host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type,
+ id: `decision-${seq}`,
+ sessionId: host.sessionId,
+ seq,
+ ts: new Date().toISOString(),
+ payload,
+ }));
+ sendHostEvent("connection.opened", 1, {});
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+
+ sendHostEvent("approval.requested", 2, {
+ requestId: 201,
+ method: "applyPatchApproval",
+ params: { reason: "legacy patch" },
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.requested"
+ && message.payload?.requestId === 201);
+ browser.ws.send(JSON.stringify({
+ v: 1,
+ kind: "command",
+ type: "approval.respond",
+ commandId: "legacy-approval-201",
+ payload: { requestId: 201, decision: "approved" },
+ }));
+ const legacyCommand = await host.wait((message) => message.kind === "command"
+ && message.type === "approval.respond"
+ && message.payload?.requestId === 201);
+ assert.deepEqual(legacyCommand.payload.response, { decision: "approved" });
+
+ sendHostEvent("approval.requested", 3, {
+ requestId: 202,
+ method: "item/commandExecution/requestApproval",
+ params: { command: "echo conflict" },
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.requested"
+ && message.payload?.requestId === 202);
+ browser.ws.send(JSON.stringify({
+ v: 1,
+ kind: "command",
+ type: "approval.respond",
+ commandId: "conflicting-approval-202",
+ payload: {
+ requestId: 202,
+ decision: "deny",
+ response: { decision: "accept" },
+ },
+ }));
+ const rejected = await browser.wait((message) => message.type === "response.rejected"
+ && message.requestId === 202);
+ assert.equal(rejected.code, "decision_mismatch");
+ assert.equal(relay.pendingServerRequests.size, 2);
+
+ sendHostEvent("approval.requested", 4, {
+ requestId: 203,
+ method: "item/commandExecution/requestApproval",
+ params: { command: "echo mixed-tag" },
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.requested"
+ && message.payload?.requestId === 203);
+ browser.ws.send(JSON.stringify({
+ v: 1,
+ kind: "command",
+ type: "approval.respond",
+ commandId: "mixed-tag-approval-203",
+ payload: {
+ requestId: 203,
+ decision: "allow",
+ response: {
+ decision: {
+ acceptWithExecpolicyAmendment: { execpolicy_amendment: ["echo"] },
+ futurePolicyGrant: { scope: "all" },
+ },
+ },
+ },
+ }));
+ const mixedRejected = await browser.wait((message) => message.type === "response.rejected"
+ && message.requestId === 203);
+ assert.equal(mixedRejected.code, "invalid_response");
+ assert.equal(relay.pendingServerRequests.size, 3);
+});
+
+test("host command result cache is unavailable offline and isolated by host session", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const sendReady = (host, seq, id) => host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "connection.opened",
+ id,
+ sessionId: host.sessionId,
+ seq,
+ ts: new Date().toISOString(),
+ payload: {},
+ }));
+ const sendCommandResult = (host, seq, id, commandId, threadId) => host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "command.result",
+ id,
+ sessionId: host.sessionId,
+ seq,
+ ts: new Date().toISOString(),
+ payload: {
+ commandId,
+ method: "thread/start",
+ ok: true,
+ result: { thread: { id: threadId } },
+ },
+ }));
+
+ const host1 = await connectHost(base, "separate-host-token", "host-session-1");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host1.ws.close());
+ t.after(() => browser.ws.close());
+ sendReady(host1, 1, "host1-ready");
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+
+ const commandId = "reused-command-id";
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId,
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ await host1.wait((message) => message.kind === "command" && message.commandId === commandId);
+ sendCommandResult(host1, 2, "host1-command-result", commandId, "thread-from-session-1");
+ const firstResult = await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result"
+ && message.payload?.commandId === commandId
+ && message.payload?.result?.thread?.id === "thread-from-session-1");
+ assert.equal(firstResult.payload.ok, true);
+
+ const uncertainCommandId = "uncertain-command-id";
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: uncertainCommandId,
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ await host1.wait((message) => message.kind === "command" && message.commandId === uncertainCommandId);
+
+ host1.ws.close();
+ await waitFor(() => relay.hostClient === null);
+ await browser.wait((message) => message.type === "command.result"
+ && message.commandId === uncertainCommandId
+ && message.uncertain === true);
+
+ // A stale success (or uncertain disconnect result) must not be replayed
+ // while no host is connected.
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId,
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const offline = await browser.wait((message) => message.type === "command.rejected" && message.commandId === commandId);
+ assert.equal(offline.code, "app_not_ready");
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: uncertainCommandId,
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const uncertainOffline = await browser.wait((message) => message.type === "command.rejected" && message.commandId === uncertainCommandId);
+ assert.equal(uncertainOffline.code, "app_not_ready");
+
+ // A reconnect carrying the same stable session id retains normal command
+ // idempotency and returns the cached result without forwarding a command.
+ const sameSessionHost = await connectHost(base, "separate-host-token", "host-session-1");
+ t.after(() => sameSessionHost.ws.close());
+ sendReady(sameSessionHost, 1, "same-session-ready");
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "connection.opened"
+ && message.payload?.source === "vscode-host");
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId,
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const replay = await browser.wait((message) => message.type === "command.result"
+ && message.cached === true
+ && message.commandId === commandId);
+ assert.equal(replay.result.thread.id, "thread-from-session-1");
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ assert.equal(sameSessionHost.messages.some((message) => message.kind === "command" && message.commandId === commandId), false);
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: uncertainCommandId,
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ await sameSessionHost.wait((message) => message.kind === "command" && message.commandId === uncertainCommandId);
+ sendCommandResult(sameSessionHost, 2, "same-session-uncertain-result", uncertainCommandId, "thread-after-uncertain");
+ const uncertainRetry = await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result"
+ && message.payload?.commandId === uncertainCommandId
+ && message.payload?.result?.thread?.id === "thread-after-uncertain");
+ assert.equal(uncertainRetry.payload.ok, true);
+
+ sameSessionHost.ws.close();
+ await waitFor(() => relay.hostClient === null);
+
+ // A different host session cannot reuse the old command id; it must receive
+ // a fresh command even though the browser retries the same id.
+ const host2 = await connectHost(base, "separate-host-token", "host-session-2");
+ t.after(() => host2.ws.close());
+ sendReady(host2, 1, "host2-ready");
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "connection.opened"
+ && message.payload?.source === "vscode-host");
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId,
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const forwarded = await host2.wait((message) => message.kind === "command" && message.commandId === commandId);
+ assert.equal(forwarded.type, "thread/start");
+ sendCommandResult(host2, 2, "host2-command-result", commandId, "thread-from-session-2");
+ const secondResult = await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result"
+ && message.payload?.commandId === commandId
+ && message.payload?.result?.thread?.id === "thread-from-session-2");
+ assert.equal(secondResult.payload.ok, true);
+});
+
+test("rejects non-object websocket frames without taking down the relay", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "embedded",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ codexCommand: process.execPath,
+ codexArgs: [fakeServer],
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const ws = new WebSocket(`ws://127.0.0.1:${address.port}/ws`);
+ t.after(() => ws.close());
+ const invalidFrame = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("timed out waiting for invalid frame response")), 5_000);
+ ws.once("error", reject);
+ ws.on("message", (data) => {
+ const message = JSON.parse(data.toString());
+ if (message.code === "invalid_frame") {
+ clearTimeout(timer);
+ resolve(message);
+ }
+ });
+ });
+ await new Promise((resolve, reject) => {
+ ws.once("open", resolve);
+ ws.once("error", reject);
+ });
+ ws.send("null");
+ await invalidFrame;
+ const health = await fetch(`http://127.0.0.1:${address.port}/api/health`);
+ assert.equal(health.status, 200);
+});
+
+test("malformed percent escapes return 400 without crashing the relay", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const malformed = await fetch(`${base}/%ZZ`);
+ assert.equal(malformed.status, 400);
+ assert.equal(await malformed.text(), "Invalid URL");
+
+ const health = await fetch(`${base}/api/health`);
+ assert.equal(health.status, 200);
+});
+
+test("cleans embedded app pending commands and approvals when the child exits", async (t) => {
+ const crashServer = [
+ "const readline = require('node:readline');",
+ "const input = readline.createInterface({ input: process.stdin });",
+ "const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n');",
+ "input.on('line', (line) => {",
+ " let request; try { request = JSON.parse(line); } catch { return; }",
+ " if (request.method === 'initialize') { send({ id: request.id, result: { userAgent: 'crash-test' } }); return; }",
+ " if (request.method === 'thread/start') { send({ id: request.id, result: { thread: { id: 'thread-crash' }, cwd: '/tmp' } }); return; }",
+ " if (request.method === 'turn/start') {",
+ " send({ id: 4321, method: 'item/commandExecution/requestApproval', params: { command: 'echo crash' } });",
+ " setTimeout(() => process.exit(23), 30);",
+ " }",
+ "});",
+ ].join("\n");
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "embedded",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ codexCommand: process.execPath,
+ codexArgs: ["-e", crashServer],
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => browser.ws.close());
+ await browser.wait((message) => message.type === "session.snapshot");
+ await waitFor(() => relay.state.initialized === true);
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "crash-thread",
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const threadResult = await browser.wait((message) => message.type === "command.result"
+ && message.payload?.commandId === "crash-thread");
+ assert.equal(threadResult.payload.ok, true);
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "crash-turn",
+ method: "turn/start",
+ params: { threadId: "thread-crash", input: [{ type: "text", text: "crash" }] },
+ }));
+ await browser.wait((message) => message.type === "command.accepted" && message.commandId === "crash-turn");
+ const approval = await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.requested"
+ && String(message.payload?.requestId) === "4321");
+ assert.equal(approval.payload.method, "item/commandExecution/requestApproval");
+ assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "4321"), true);
+
+ const uncertain = await browser.wait((message) => message.type === "command.result"
+ && message.commandId === "crash-turn"
+ && message.uncertain === true);
+ assert.equal(uncertain.retryable, true);
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.expired"
+ && String(message.payload?.requestId) === "4321");
+ await waitFor(() => relay.state.app === "offline");
+ assert.equal(relay.state.initialized, false);
+ assert.equal(relay.pendingAppRequests.size, 0);
+ assert.equal(relay.pendingServerRequests.size, 0);
+ assert.equal(relay.appProcess, null);
+ assert.throws(() => relay.sendToApp({ method: "ping" }), (error) => error.code === "app_offline");
+
+ // The uncertain marker is deliberately not replayed. A retry while the app
+ // is offline receives the normal readiness error instead of a duplicate.
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "crash-turn",
+ method: "turn/start",
+ params: { threadId: "thread-crash", input: [{ type: "text", text: "retry" }] },
+ }));
+ const rejected = await browser.wait((message) => message.type === "command.rejected"
+ && message.commandId === "crash-turn");
+ assert.equal(rejected.code, "app_not_ready");
+});
+
+test("cleans host pending work on app connection.closed and honors expiry events", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const host = await connectHost(base, "separate-host-token", "cleanup-host-session");
+ const browser = await connectWs(base, "operator-test-token");
+ t.after(() => host.ws.close());
+ t.after(() => browser.ws.close());
+
+ const sendHostEvent = (type, seq, payload, id = `cleanup-${seq}`) => host.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type,
+ id,
+ sessionId: host.sessionId,
+ seq,
+ ts: new Date().toISOString(),
+ payload,
+ }));
+
+ sendHostEvent("connection.opened", 1, {});
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.opened");
+
+ // Complete one command first so the app-unavailable transition has a cache
+ // entry to invalidate as well as an in-flight command to settle.
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "host-completed-before-exit",
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ await host.wait((message) => message.kind === "command" && message.commandId === "host-completed-before-exit");
+ sendHostEvent("command.result", 2, {
+ commandId: "host-completed-before-exit",
+ method: "thread/start",
+ ok: true,
+ result: { thread: { id: "host-thread-cleanup" } },
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "command.result"
+ && message.payload?.commandId === "host-completed-before-exit");
+ assert.equal(relay.commandResults.has("host-completed-before-exit"), true);
+
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "host-pending-before-exit",
+ method: "turn/start",
+ params: { threadId: "host-thread-cleanup", input: [{ type: "text", text: "pending" }] },
+ }));
+ await host.wait((message) => message.kind === "command" && message.commandId === "host-pending-before-exit");
+ assert.equal(relay.pendingHostCommands.has("host-pending-before-exit"), true);
+
+ sendHostEvent("approval.requested", 3, {
+ requestId: 501,
+ method: "item/commandExecution/requestApproval",
+ params: { command: "echo pending approval" },
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.requested"
+ && message.payload?.requestId === 501);
+ assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "501"), true);
+
+ sendHostEvent("approval.expired", 4, {
+ requestId: 501,
+ method: "item/commandExecution/requestApproval",
+ });
+ await browser.wait((message) => message.kind === "event"
+ && message.type === "approval.expired"
+ && message.payload?.requestId === 501);
+ assert.equal([...relay.pendingServerRequests.values()].some((pending) => String(pending.appId) === "501"), false);
+
+ sendHostEvent("connection.closed", 5, { message: "embedded app exited" });
+ await browser.wait((message) => message.kind === "event" && message.type === "connection.closed");
+ const uncertain = await browser.wait((message) => message.type === "command.result"
+ && message.commandId === "host-pending-before-exit"
+ && message.uncertain === true);
+ assert.equal(uncertain.error.code, "app_unavailable");
+ await waitFor(() => relay.state.app === "offline");
+ assert.equal(relay.state.initialized, false);
+ assert.equal(relay.state.hostConnected, true);
+ assert.equal(relay.pendingHostCommands.size, 0);
+ assert.equal(relay.pendingServerRequests.size, 0);
+ assert.equal(relay.commandResults.size, 0);
+
+ // The host transport remains connected, but commands are rejected until it
+ // reports a fresh connection.opened/session snapshot.
+ browser.ws.send(JSON.stringify({
+ type: "command",
+ commandId: "host-completed-before-exit",
+ method: "thread/start",
+ params: { cwd: "/tmp", sandbox: "workspace-write" },
+ }));
+ const rejected = await browser.wait((message) => message.type === "command.rejected"
+ && message.commandId === "host-completed-before-exit");
+ assert.equal(rejected.code, "app_not_ready");
+});
+
+test("ignores a stale host connection.closed frame after host replacement", async (t) => {
+ const relay = new CodexRelay({
+ host: "127.0.0.1",
+ port: 0,
+ mode: "host",
+ operatorToken: "operator-test-token",
+ viewerToken: "viewer-test-token",
+ hostToken: "separate-host-token",
+ });
+ await relay.start();
+ t.after(() => relay.stop());
+ const address = relay.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const firstHost = await connectHost(base, "separate-host-token", "stale-session-1");
+ t.after(() => firstHost.ws.close());
+ firstHost.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "connection.opened",
+ sessionId: firstHost.sessionId,
+ seq: 1,
+ payload: {},
+ }));
+ await waitFor(() => relay.state.app === "ready");
+ const staleClient = relay.hostClient;
+ firstHost.ws.close();
+ await waitFor(() => relay.hostClient === null);
+
+ const secondHost = await connectHost(base, "separate-host-token", "stale-session-2");
+ t.after(() => secondHost.ws.close());
+ secondHost.ws.send(JSON.stringify({
+ v: 1,
+ kind: "event",
+ type: "connection.opened",
+ sessionId: secondHost.sessionId,
+ seq: 1,
+ payload: {},
+ }));
+ await waitFor(() => relay.state.app === "ready" && relay.hostClient?.sessionId === "stale-session-2");
+ const sequenceBefore = relay.nextSeq;
+
+ relay.ingestHostEvent(staleClient, {
+ v: 1,
+ kind: "event",
+ type: "connection.closed",
+ sessionId: "stale-session-1",
+ seq: 99,
+ payload: { message: "late old app exit" },
+ });
+
+ assert.equal(relay.state.app, "ready");
+ assert.equal(relay.state.initialized, true);
+ assert.equal(relay.state.hostConnected, true);
+ assert.equal(relay.state.hostSessionId, "stale-session-2");
+ assert.equal(relay.nextSeq, sequenceBefore);
+});
diff --git a/aether-vscodex/test/switchable-agent-adapter.test.js b/aether-vscodex/test/switchable-agent-adapter.test.js
new file mode 100644
index 000000000..643807a46
--- /dev/null
+++ b/aether-vscodex/test/switchable-agent-adapter.test.js
@@ -0,0 +1,387 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const test = require("node:test");
+
+const { SwitchableAgentAdapter } = require("../vscode-extension/dist/switchableAgentAdapter.js");
+
+class FakeAdapter {
+ constructor(name, options = {}) {
+ this.name = name;
+ this.options = options;
+ this.listeners = new Set();
+ this.calls = [];
+ this.disposed = false;
+ this.snapshotValue = options.snapshot ?? idleSnapshot(name);
+ }
+
+ async start() {
+ this.calls.push(["start"]);
+ this.emit({ type: "candidate.starting", payload: { name: this.name } });
+ if (this.options.startGate) await this.options.startGate.promise;
+ if (this.options.startError) throw this.options.startError;
+ this.emit({ type: "connection.opened", payload: { name: this.name } });
+ }
+
+ async startThread(params = {}) { return this.record("startThread", params); }
+ async newSession(params = {}) { return this.record("newSession", params); }
+ async startTurn(params) { return this.record("startTurn", params); }
+ async steerTurn(params) { return this.record("steerTurn", params); }
+ async updateThreadSettings(params) { return this.record("updateThreadSettings", params); }
+ async listSessions(params = {}) { return this.record("listSessions", params); }
+ async selectSession(params) { return this.record("selectSession", params); }
+ async interruptTurn(params) { return this.record("interruptTurn", params); }
+ async sendInput(text, params = {}) { return this.record("sendInput", { text, ...params }); }
+ async cancel(taskId, params = {}) { return this.record("cancel", { taskId, ...params }); }
+ async respondApproval(requestId, decision, reason, response) {
+ return this.record("respondApproval", { requestId, decision, reason, response });
+ }
+ async denyPending(reason) { this.calls.push(["denyPending", reason]); }
+
+ async snapshot() {
+ this.calls.push(["snapshot"]);
+ return structuredClone(this.snapshotValue);
+ }
+
+ onEvent(listener) {
+ this.listeners.add(listener);
+ return { dispose: () => this.listeners.delete(listener) };
+ }
+
+ emit(event) {
+ for (const listener of this.listeners) listener(event);
+ }
+
+ async dispose() {
+ this.calls.push(["dispose"]);
+ this.disposed = true;
+ }
+
+ record(method, params) {
+ this.calls.push([method, params]);
+ return { adapter: this.name, method, params };
+ }
+}
+
+function idleSnapshot(name) {
+ return {
+ threadId: `${name}-thread`,
+ turnId: null,
+ state: "idle",
+ pendingApprovals: [],
+ pendingRequests: [],
+ outputTail: "",
+ metadata: { adapter: name },
+ };
+}
+
+function deferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
+ return { promise, resolve, reject };
+}
+
+test("sync mode decorates snapshots and enforces VS Code-owned navigation", async () => {
+ const sync = new FakeAdapter("sync");
+ const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
+ await adapter.start();
+
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.metadata.adapter, "sync");
+ assert.equal(snapshot.metadata.mode, "sync");
+ assert.equal(snapshot.metadata.controlMode, "sync");
+ assert.equal(snapshot.metadata.modeEpoch, 0);
+ assert.deepEqual(snapshot.metadata.capabilities, {
+ followsVscodeRoute: true,
+ sessionList: false,
+ sessionSelect: false,
+ sessionCreate: false,
+ threadSettings: true,
+ });
+
+ await assert.rejects(adapter.listSessions(), /unavailable in sync mode/);
+ await assert.rejects(adapter.selectSession({ threadId: "other" }), /unavailable in sync mode/);
+ await assert.rejects(adapter.newSession(), /unavailable in sync mode/);
+ await assert.rejects(adapter.startThread(), /unavailable in sync mode/);
+ assert.equal((await adapter.sendInput("hello")).adapter, "sync");
+ assert.equal((await adapter.updateThreadSettings({ model: "codex" })).adapter, "sync");
+ await adapter.dispose();
+});
+
+test("async mode proxies the complete AgentAdapter surface", async () => {
+ const independent = new FakeAdapter("async");
+ const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
+ await adapter.start();
+
+ await adapter.startThread({ cwd: "/workspace" });
+ await adapter.newSession({ model: "codex" });
+ await adapter.startTurn({ text: "start" });
+ await adapter.steerTurn({ text: "steer" });
+ await adapter.updateThreadSettings({ effort: "high" });
+ await adapter.listSessions({ limit: 10 });
+ await adapter.selectSession({ threadId: "thread-2" });
+ await adapter.interruptTurn({ turnId: "turn-1" });
+ await adapter.sendInput("input", { source: "web" });
+ await adapter.cancel("turn-2", { reason: "user" });
+ await adapter.respondApproval(7, "allow", "approved", { decision: "accept" });
+ await adapter.denyPending("offline");
+
+ assert.deepEqual(
+ independent.calls.map(([method]) => method).filter((method) => !["start", "snapshot", "dispose"].includes(method)),
+ [
+ "startThread",
+ "newSession",
+ "startTurn",
+ "steerTurn",
+ "updateThreadSettings",
+ "listSessions",
+ "selectSession",
+ "interruptTurn",
+ "sendInput",
+ "cancel",
+ "respondApproval",
+ "denyPending",
+ ],
+ );
+ await adapter.dispose();
+});
+
+test("session/new falls back to thread/start for a minimal async adapter", async () => {
+ const independent = new FakeAdapter("async");
+ independent.newSession = undefined;
+ const adapter = new SwitchableAgentAdapter({ initialMode: "async", createAdapter: () => independent });
+ await adapter.start();
+
+ const result = await adapter.newSession({ cwd: "/workspace" });
+ assert.equal(result.method, "startThread");
+ assert.equal((await adapter.snapshot()).metadata.capabilities.sessionCreate, true);
+ await adapter.dispose();
+});
+
+test("mode switch commits atomically, buffers candidate events, and isolates the old generation", async () => {
+ const sync = new FakeAdapter("sync");
+ const gate = deferred();
+ const asyncAdapter = new FakeAdapter("async", { startGate: gate });
+ const adapter = new SwitchableAgentAdapter({
+ initialMode: "sync",
+ createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
+ });
+ const events = [];
+ adapter.onEvent((event) => events.push(`${event.type}:${event.payload.name ?? event.payload.controlMode ?? ""}`));
+ await adapter.start();
+ events.length = 0;
+
+ const switching = adapter.setControlMode({ mode: "async" });
+ await Promise.resolve();
+ sync.emit({ type: "old.while-current", payload: { name: "sync" } });
+ assert.deepEqual(events, ["old.while-current:sync"]);
+ await assert.rejects(adapter.sendInput("racing input"), /mode is switching/);
+ gate.resolve();
+
+ const result = await switching;
+ assert.deepEqual(result, {
+ changed: true,
+ controlMode: "async",
+ previousControlMode: "sync",
+ modeEpoch: 1,
+ });
+ assert.equal(sync.disposed, true);
+ assert.equal(adapter.getControlMode(), "async");
+ assert.ok(events.indexOf("control.mode.changed:async") < events.indexOf("candidate.starting:async"));
+ assert.ok(events.includes("connection.opened:async"));
+
+ sync.emit({ type: "old.after-commit", payload: { name: "sync" } });
+ asyncAdapter.emit({ type: "new.after-commit", payload: { name: "async" } });
+ assert.equal(events.includes("old.after-commit:sync"), false);
+ assert.equal(events.includes("new.after-commit:async"), true);
+
+ const snapshot = await adapter.snapshot();
+ assert.equal(snapshot.metadata.modeEpoch, 1);
+ assert.deepEqual(snapshot.metadata.capabilities, {
+ followsVscodeRoute: false,
+ sessionList: true,
+ sessionSelect: true,
+ sessionCreate: true,
+ threadSettings: true,
+ });
+ assert.equal((await adapter.listSessions()).adapter, "async");
+ assert.equal((await adapter.newSession()).adapter, "async");
+ await adapter.dispose();
+});
+
+test("delegate snapshot events always carry authoritative mode metadata", async () => {
+ const sync = new FakeAdapter("sync");
+ const asyncAdapter = new FakeAdapter("async");
+ const adapter = new SwitchableAgentAdapter({
+ initialMode: "sync",
+ createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
+ });
+ const snapshots = [];
+ adapter.onEvent((event) => {
+ if (event.type === "session.snapshot") snapshots.push(event.payload);
+ });
+ await adapter.start();
+
+ sync.emit({
+ type: "session.snapshot",
+ threadId: "sync-thread-2",
+ payload: { threadId: "sync-thread-2", metadata: { adapter: "sync", route: "/thread/2" } },
+ });
+ assert.deepEqual(snapshots.at(-1).metadata, {
+ adapter: "sync",
+ route: "/thread/2",
+ mode: "sync",
+ controlMode: "sync",
+ modeEpoch: 0,
+ capabilities: {
+ followsVscodeRoute: true,
+ sessionList: false,
+ sessionSelect: false,
+ sessionCreate: false,
+ threadSettings: true,
+ },
+ });
+
+ await adapter.setControlMode({ mode: "async" });
+ snapshots.length = 0;
+ asyncAdapter.emit({
+ type: "session.snapshot",
+ threadId: "async-thread-2",
+ payload: { threadId: "async-thread-2", metadata: { adapter: "async", title: "Second" } },
+ });
+ assert.equal(snapshots.length, 1);
+ assert.equal(snapshots[0].metadata.adapter, "async");
+ assert.equal(snapshots[0].metadata.title, "Second");
+ assert.equal(snapshots[0].metadata.controlMode, "async");
+ assert.equal(snapshots[0].metadata.modeEpoch, 1);
+ assert.equal(snapshots[0].metadata.capabilities.followsVscodeRoute, false);
+ assert.equal(snapshots[0].metadata.capabilities.sessionSelect, true);
+ await adapter.dispose();
+});
+
+test("active turns and pending requests prevent a mode switch", async (t) => {
+ const cases = [
+ ["active turn", { ...idleSnapshot("sync"), turnId: "turn-1", state: "active" }],
+ ["active state before a turn id arrives", { ...idleSnapshot("sync"), state: "in_progress" }],
+ ["active runtime flag", { ...idleSnapshot("sync"), activeFlags: ["thinking"] }],
+ ["pending approval", {
+ ...idleSnapshot("sync"),
+ pendingApprovals: [{ requestId: 1, method: "approval", action: "run", risk: "low", summary: "run", createdAt: 1, payload: {} }],
+ }],
+ ["pending input", {
+ ...idleSnapshot("sync"),
+ pendingRequests: [{ requestId: "input-1", method: "item/tool/requestUserInput" }],
+ }],
+ ];
+
+ for (const [name, snapshot] of cases) {
+ await t.test(name, async () => {
+ const sync = new FakeAdapter("sync", { snapshot });
+ let factoryCalls = 0;
+ const adapter = new SwitchableAgentAdapter({
+ initialMode: "sync",
+ createAdapter: (mode) => {
+ factoryCalls += 1;
+ return mode === "sync" ? sync : new FakeAdapter("async");
+ },
+ });
+ await adapter.start();
+ await assert.rejects(adapter.setControlMode({ mode: "async" }), /turn or request is active/);
+ assert.equal(factoryCalls, 1, "busy checks happen before creating a second adapter");
+ assert.equal(adapter.getControlMode(), "sync");
+ await adapter.dispose();
+ });
+ }
+});
+
+test("candidate startup failure leaves the old adapter authoritative", async () => {
+ const sync = new FakeAdapter("sync");
+ const failed = new FakeAdapter("async", { startError: new Error("candidate failed") });
+ const adapter = new SwitchableAgentAdapter({
+ initialMode: "sync",
+ createAdapter: (mode) => mode === "sync" ? sync : failed,
+ });
+ const events = [];
+ adapter.onEvent((event) => events.push(event.type));
+ await adapter.start();
+ events.length = 0;
+
+ await assert.rejects(adapter.setControlMode({ controlMode: "async" }), /candidate failed/);
+ assert.equal(adapter.getControlMode(), "sync");
+ assert.equal(failed.disposed, true);
+ assert.equal(sync.disposed, false);
+ assert.equal(events.includes("candidate.starting"), false, "failed candidate events stay private");
+ assert.equal((await adapter.sendInput("still attached")).adapter, "sync");
+ assert.equal((await adapter.snapshot()).metadata.modeEpoch, 0);
+ await adapter.dispose();
+});
+
+test("a mode factory cannot reuse the currently active adapter instance", async () => {
+ const shared = new FakeAdapter("shared");
+ const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => shared });
+ await adapter.start();
+
+ await assert.rejects(adapter.setControlMode({ mode: "async" }), /must return a distinct adapter/);
+ assert.equal(adapter.getControlMode(), "sync");
+ assert.equal(shared.disposed, false);
+ assert.equal((await adapter.sendInput("still live")).adapter, "shared");
+ await adapter.dispose();
+});
+
+test("listener failures cannot turn a committed switch into a rejected command", async () => {
+ const sync = new FakeAdapter("sync");
+ const asyncAdapter = new FakeAdapter("async");
+ const adapter = new SwitchableAgentAdapter({
+ initialMode: "sync",
+ createAdapter: (mode) => mode === "sync" ? sync : asyncAdapter,
+ });
+ adapter.onEvent(() => { throw new Error("consumer failed"); });
+ await adapter.start();
+
+ const result = await adapter.setControlMode({ mode: "async" });
+ assert.equal(result.changed, true);
+ assert.equal(adapter.getControlMode(), "async");
+ assert.equal(sync.disposed, true);
+ await adapter.dispose();
+});
+
+test("a turn that appears while the candidate starts aborts before commit", async () => {
+ const sync = new FakeAdapter("sync");
+ const gate = deferred();
+ const candidate = new FakeAdapter("async", { startGate: gate });
+ const adapter = new SwitchableAgentAdapter({
+ initialMode: "sync",
+ createAdapter: (mode) => mode === "sync" ? sync : candidate,
+ });
+ await adapter.start();
+
+ const switching = adapter.setControlMode({ mode: "async" });
+ await Promise.resolve();
+ sync.snapshotValue.turnId = "turn-race";
+ sync.snapshotValue.state = "active";
+ gate.resolve();
+
+ await assert.rejects(switching, /turn or request is active/);
+ assert.equal(adapter.getControlMode(), "sync");
+ assert.equal(candidate.disposed, true);
+ assert.equal(sync.disposed, false);
+ sync.snapshotValue.turnId = null;
+ sync.snapshotValue.state = "idle";
+ await adapter.dispose();
+});
+
+test("control mode validation and idempotent switches are explicit", async () => {
+ const sync = new FakeAdapter("sync");
+ const adapter = new SwitchableAgentAdapter({ initialMode: "sync", createAdapter: () => sync });
+ await adapter.start();
+
+ await assert.rejects(adapter.setControlMode({ mode: "attach" }), /must be sync or async/);
+ assert.deepEqual(await adapter.setControlMode({ mode: "sync" }), {
+ changed: false,
+ controlMode: "sync",
+ previousControlMode: "sync",
+ modeEpoch: 0,
+ });
+ await adapter.dispose();
+});
diff --git a/aether-vscodex/vscode-extension/.gitignore b/aether-vscodex/vscode-extension/.gitignore
new file mode 100644
index 000000000..a08e1da2d
--- /dev/null
+++ b/aether-vscodex/vscode-extension/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+*.vsix
diff --git a/aether-vscodex/vscode-extension/.vscodeignore b/aether-vscodex/vscode-extension/.vscodeignore
new file mode 100644
index 000000000..26b7df07e
--- /dev/null
+++ b/aether-vscodex/vscode-extension/.vscodeignore
@@ -0,0 +1,9 @@
+src/**
+.gitignore
+tsconfig.json
+**/*.map
+node_modules/@types/**
+node_modules/typescript/**
+node_modules/.package-lock.json
+*.tsbuildinfo
+*.vsix
diff --git a/aether-vscodex/vscode-extension/LICENSE b/aether-vscodex/vscode-extension/LICENSE
new file mode 100644
index 000000000..f0d01c030
--- /dev/null
+++ b/aether-vscodex/vscode-extension/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Codex Remote Collaboration contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/aether-vscodex/vscode-extension/README.md b/aether-vscodex/vscode-extension/README.md
new file mode 100644
index 000000000..a2d479a00
--- /dev/null
+++ b/aether-vscodex/vscode-extension/README.md
@@ -0,0 +1,237 @@
+# Codex Remote Collaboration VS Code Bridge
+
+This extension connects local and Aether relay channels to one switchable Codex
+control host. **Synchronous mode** follows the conversation currently shown by
+the official Codex VS Code extension through its private IPC protocol and does
+not spawn a `codex` process. **Asynchronous mode** starts an independent
+app-server and lets the Web UI list, resume, create, and select conversations.
+
+The attached conversation remains visible and usable in the official Codex
+panel. Remote operators can observe its output, submit a new turn or steer the
+active turn, interrupt it, and answer supported approval/input requests.
+
+The mode can be changed from the Web UI without reconnecting either relay.
+Synchronous mode makes the official panel the only conversation-navigation
+owner; asynchronous mode restores the browser history and new-conversation
+actions. A running turn or pending request blocks mode changes.
+
+## Requirements
+
+- The official `openai.chatgpt` VS Code extension is installed and signed in.
+- The target Codex conversation is open and owned by that extension.
+- The bridge and official extension run as the same OS user. The default Unix
+ socket is `$CODEX_HOME/ipc/ipc.sock`, normally `~/.codex/ipc/ipc.sock`.
+- For a loopback `ws://` URL, the extension starts and owns its bundled relay
+ automatically. Remote and `wss://` relay URLs remain externally hosted.
+
+The IPC follower protocol is private and versioned, not a public OpenAI API.
+An official extension update can require a compatible bridge update. Strict
+stream-version checks are enabled by default so an unknown protocol fails
+closed instead of being interpreted optimistically.
+
+## Build and install
+
+```sh
+npm install
+npm run check
+npm run build
+npx --yes @vscode/vsce package
+code --install-extension codex-remote-collab-0.4.0.vsix --force
+```
+
+Run **Developer: Reload Window** after installing or replacing the VSIX.
+
+## Configure control modes
+
+For the local default, no separate relay command is required. The extension
+starts the bundled relay on the host and port from `codexRemoteCollab.localRelayUrl`.
+To run the development relay manually, disable
+`codexRemoteCollab.autoStartLocalRelay` and use:
+
+```sh
+HOST=127.0.0.1 PORT=8787 CODEX_REMOTE_MODE=host npm start
+```
+
+To opt into authentication later, set `CODEX_REMOTE_AUTH=required` and the three
+token variables before starting the relay.
+
+Set the extension configuration:
+
+```json
+{
+ "codexRemoteCollab.localRelayUrl": "ws://127.0.0.1:8787/v1/connect",
+ "codexRemoteCollab.controlMode": "sync",
+ "codexRemoteCollab.autoDiscoverThread": true,
+ "codexRemoteCollab.autoStart": true
+}
+```
+
+Then:
+
+1. Open the target conversation in the official Codex panel.
+2. Reload VS Code once after installing the companion extension. The local relay and bridge start automatically; no token is needed for loopback. The status item opens the Web console and is not a connect/disconnect toggle.
+3. Open the relay web console; it connects automatically on localhost. The web UI uses a
+ Codex-style conversation stream with a bottom composer; Enter sends and Shift+Enter
+ inserts a newline. There is no separate connect/disconnect step for the local relay.
+
+If the browser says that it is waiting for the VS Code host or the recent-session list is
+empty, verify that `codexRemoteCollab.localRelayUrl` uses the same port as the relay and run
+**Developer: Reload Window**. Keep `codexRemoteCollab.threadId` empty unless a specific
+conversation must be pinned; an old closed ID can prevent startup until it is cleared.
+
+When authentication is enabled, run **Codex Remote: Set Relay Token** with the
+host token. It is stored in `vscode.SecretStorage`, not in settings; the browser
+uses the operator or viewer token separately.
+
+With no configured thread ID, the bridge ranks recent VS Code rollout metadata
+and shows only candidates verified by live IPC owner discovery and a matching
+follower snapshot. Explicit Codex Desktop tasks, closed, stale, and other
+non-attachable history entries are omitted.
+In synchronous mode, switching the conversation in the official Codex panel
+also switches the Web projection after the new owner snapshot is ready. The
+Web UI cannot list, select, or create conversations in this mode. Switch to
+asynchronous mode when the browser should own conversation navigation.
+To avoid ambiguity when several Codex windows are open, run **Codex Remote: Set Existing Thread ID**.
+An empty value restores automatic discovery.
+
+Useful commands:
+
+- **Codex Remote: Start Bridge** / **Stop Bridge**
+- **Codex Remote: Set Existing Thread ID**
+- **Codex Remote: Set Relay Token**
+- **Codex Remote: Pair with Aether**
+- **Codex Remote: Configure Aether Cloud Relay**
+- **Codex Remote: Send Input**
+- **Codex Remote: Show Snapshot**
+
+## Settings
+
+| Setting | Default | Meaning |
+| --- | --- | --- |
+| `codexRemoteCollab.controlMode` | `sync` | `sync` follows VS Code; `async` owns an independent app-server. |
+| `codexRemoteCollab.localRelayUrl` | `ws://127.0.0.1:8787/v1/connect` | Bundled loopback relay used by the local Web control. |
+| `codexRemoteCollab.aetherUrl` | empty | Aether origin remembered by the pairing command. |
+| `codexRemoteCollab.cloudRelayUrl` | empty | Aether WebSocket relay URL populated by pairing. |
+| `codexRemoteCollab.threadId` | empty | Exact existing conversation ID; empty enables discovery. |
+| `codexRemoteCollab.autoDiscoverThread` | `true` | Discover and owner-check a local VS Code session. |
+| `codexRemoteCollab.followVscodeSession` | `true` | Legacy compatibility setting; synchronous mode always follows VS Code. |
+| `codexRemoteCollab.ipcSocketPath` | empty | Override the local IPC socket path. |
+| `codexRemoteCollab.hostId` | `local` | Owner-discovery host identifier. |
+| `codexRemoteCollab.ipcStrictVersions` | `true` | Reject unsupported stream protocol versions. |
+| `codexRemoteCollab.approvalTimeoutMs` | `300000` | Deny an unanswered request locally after this delay. |
+| `codexRemoteCollab.allowHighRiskApprovals` | `false` | Permit remote high-risk approvals when explicitly enabled. |
+
+`codexRemoteCollab.codexCommand`, `codexArgs`, and `defaultCwd` apply only to
+asynchronous mode. The deprecated `mode=attach/spawn` values map to
+`controlMode=sync/async` when no explicit control mode exists.
+
+## Pair with Aether
+
+The local relay stays enabled after cloud pairing. In Aether, open **Codex remote
+control** and generate a one-time code. Then run **Codex Remote: Pair with Aether**
+from the VS Code Command Palette, enter the Aether server URL and the code, and
+the bridge will connect to both relays. The long-lived device credential is stored
+only in VS Code SecretStorage. Revoke a lost or retired device from the Aether page.
+
+## Relay behavior
+
+The bridge sends a `hello` and, when a relay token is configured, a separate
+bearer-auth frame over an outbound WebSocket. It publishes normalized events including:
+
+- `connection.opened` / `connection.closed`
+- `session.snapshot`
+- `output.snapshot` / `output.chunk`
+- `task.started` / `task.finished` / `task.cancelled`
+- `approval.requested` / `approval.resolved` / `approval.expired`
+- `input.requested` / `input.resolved` / `input.expired`
+
+Remote commands are mapped to the existing conversation owner:
+
+- `control/mode/set` atomically switches between `sync` and `async`.
+- `session/list`, `session/select`, and `session/new` are available only in
+ asynchronous mode and map to `thread/list`, `thread/resume`, and `thread/start`.
+- `turn/start` starts a turn in the attached thread.
+- `turn/steer` adds input to the active turn.
+- `turn/interrupt` interrupts the expected active turn.
+- `approval.respond`, `input.respond`, and `server.request.respond` preserve the
+ original request ID and use method-specific follower responses.
+- `thread/start` is deliberately rejected in synchronous mode because VS Code
+ owns conversation navigation there.
+
+The browser never connects directly to the IPC socket. Relay and host both
+enforce role/capability checks; high-risk command approval remains disabled
+unless the local VS Code setting opts in.
+
+## Supported follower requests
+
+- `item/commandExecution/requestApproval`
+- `item/fileChange/requestApproval`
+- `item/permissions/requestApproval`
+- `item/tool/requestUserInput`
+- `mcpServer/elicitation/request`
+- legacy `applyPatchApproval` and `execCommandApproval`
+
+Unanswered requests expire with a local deny. JSON-RPC numeric and string IDs
+remain distinct, and a response can be submitted only once.
+
+## Legacy mode migration
+
+The old setting remains accepted:
+
+```json
+{
+ "codexRemoteCollab.mode": "spawn",
+ "codexRemoteCollab.codexCommand": "/absolute/path/to/codex",
+ "codexRemoteCollab.codexArgs": ["app-server", "--stdio"]
+}
+```
+
+It maps to `controlMode=async`. Prefer the new setting directly. A
+`spawn codex ENOENT` error belongs only to asynchronous mode; it is not a
+synchronous-mode prerequisite or a PATH problem that needs fixing for
+existing-session control.
+
+The standalone `npm run start:stdio` entry point and `createBridge()` helper
+also retain the legacy app-server adapter for compatibility.
+
+## Embedding the attach adapter
+
+The reusable exports are in `src/index.ts`:
+
+```ts
+import {
+ CodexIpcAgentAdapter,
+ RelayClient,
+ RelayHost,
+} from "codex-remote-collab";
+
+const adapter = new CodexIpcAgentAdapter({
+ threadId: process.env.CODEX_THREAD_ID,
+ autoDiscoverThread: true,
+});
+const relay = new RelayClient({
+ url: "wss://relay.example.test/v1/connect",
+ accessToken: process.env.CODEX_REMOTE_HOST_TOKEN,
+});
+const host = new RelayHost({ adapter, relay });
+await host.start();
+```
+
+`CodexIpcClient` is exported separately for protocol fixtures and diagnostics.
+Use `followConversation()` before follower mutations, and always target the
+owner returned by `findThreadOwner()`.
+
+## Troubleshooting
+
+- **No existing session found:** open the target official Codex conversation,
+ keep that VS Code window running, then retry or set its exact thread ID.
+- **Owner not found:** the rollout exists on disk but no live official client
+ currently owns it. Reopen the conversation in the Codex panel.
+- **IPC version mismatch:** update this bridge for the installed official
+ extension. Disabling strict versions is diagnostic only.
+- **Relay stays at waiting for host:** confirm host mode, relay URL, and that no
+ second host is already connected. If authentication is enabled, also check the
+ host token.
+- **Old `spawn codex ENOENT` message:** install version `0.4.0`, reload VS Code,
+ and verify `codexRemoteCollab.controlMode` is `sync` unless independent
+ conversations are intended.
diff --git a/aether-vscodex/vscode-extension/l10n/bundle.l10n.json b/aether-vscodex/vscode-extension/l10n/bundle.l10n.json
new file mode 100644
index 000000000..6aa35d6db
--- /dev/null
+++ b/aether-vscodex/vscode-extension/l10n/bundle.l10n.json
@@ -0,0 +1,56 @@
+{
+ "A non-empty Aether device credential is required.": "A non-empty Aether device credential is required.",
+ "Aether cloud connection removed. Local control remains enabled.": "Aether cloud connection removed. Local control remains enabled.",
+ "Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.",
+ "Aether cloud relay WebSocket URL": "Aether cloud relay WebSocket URL",
+ "Aether pairing completed. Local and cloud control are both active.": "Aether pairing completed. Local and cloud control are both active.",
+ "Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.",
+ "Aether returned an invalid pairing response.": "Aether returned an invalid pairing response.",
+ "Aether server URL": "Aether server URL",
+ "Attached to the existing Codex conversation. Click to open the web control.": "Attached to the existing Codex conversation. Click to open the web control.",
+ "Bridge connected. Click to open the web control.": "Bridge connected. Click to open the web control.",
+ "Bridge paused. Click to open the web control and resume automatically.": "Bridge paused. Click to open the web control and resume automatically.",
+ "Codex Remote Collaboration": "Codex Remote Collaboration",
+ "Codex Remote will attach to {0} after the next bridge start.": "Codex Remote will attach to {0} after the next bridge start.",
+ "Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.",
+ "Connecting to the local Codex collaboration service": "Connecting to the local Codex collaboration service",
+ "Device credential from the Aether pairing flow": "Device credential from the Aether pairing flow",
+ "Enter a valid URL.": "Enter a valid URL.",
+ "Enter a valid WebSocket URL.": "Enter a valid WebSocket URL.",
+ "Enter the 8-character pairing code.": "Enter the 8-character pairing code.",
+ "Enter the Aether server URL.": "Enter the Aether server URL.",
+ "Existing Codex conversation ID (leave blank for auto-discovery)": "Existing Codex conversation ID (leave blank for auto-discovery)",
+ "Independent Codex mode is connected. Click to open the web control.": "Independent Codex mode is connected. Click to open the web control.",
+ "One-time pairing code shown in Aether": "One-time pairing code shown in Aether",
+ "Relay access token (leave blank for the local relay)": "Relay access token (leave blank for the local relay)",
+ "Relay token stored in VS Code SecretStorage.": "Relay token stored in VS Code SecretStorage.",
+ "Remote Aether connections must use wss://.": "Remote Aether connections must use wss://.",
+ "Remote Aether servers must use https://.": "Remote Aether servers must use https://.",
+ "Restoring the local collaboration service": "Restoring the local collaboration service",
+ "Send input to the active Codex turn": "Send input to the active Codex turn",
+ "Set codexRemoteCollab.localRelayUrl before starting the bridge.": "Set codexRemoteCollab.localRelayUrl before starting the bridge.",
+ "Start the Codex remote bridge first.": "Start the Codex remote bridge first.",
+ "Starting the independent Codex mode.": "Starting the independent Codex mode.",
+ "Starting {0}": "Starting {0}",
+ "The Codex conversation is not connected": "The Codex conversation is not connected",
+ "The Codex executable is unavailable": "The Codex executable is unavailable",
+ "The Codex remote bridge attached to the existing VS Code Codex conversation.": "The Codex remote bridge attached to the existing VS Code Codex conversation.",
+ "The Codex remote bridge is already running.": "The Codex remote bridge is already running.",
+ "The Codex remote collaboration bridge connected.": "The Codex remote collaboration bridge connected.",
+ "The independent Codex mode is not connected": "The independent Codex mode is not connected",
+ "The independent Codex remote mode connected.": "The independent Codex remote mode connected.",
+ "The bridge is not connected": "The bridge is not connected",
+ "The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.",
+ "The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.",
+ "The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.",
+ "Unable to pair with Aether: {0}": "Unable to pair with Aether: {0}",
+ "Unable to restore the local collaboration service": "Unable to restore the local collaboration service",
+ "Unable to send Codex input: {0}": "Unable to send Codex input: {0}",
+ "Unable to start the Codex remote bridge: {0}": "Unable to start the Codex remote bridge: {0}",
+ "Unable to start the local Codex collaboration service: {0}": "Unable to start the local Codex collaboration service: {0}",
+ "Unable to start the local collaboration service: {0}": "Unable to start the local collaboration service: {0}",
+ "Use a ws:// or wss:// URL.": "Use a ws:// or wss:// URL.",
+ "Use the Aether origin without credentials, a query, or a fragment.": "Use the Aether origin without credentials, a query, or a fragment.",
+ "Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "Waiting for a Codex conversation to open in VS Code. It will connect automatically.",
+ "codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl must be a loopback ws:// address."
+}
diff --git a/aether-vscodex/vscode-extension/l10n/bundle.l10n.zh-cn.json b/aether-vscodex/vscode-extension/l10n/bundle.l10n.zh-cn.json
new file mode 100644
index 000000000..6a2cefdc1
--- /dev/null
+++ b/aether-vscodex/vscode-extension/l10n/bundle.l10n.zh-cn.json
@@ -0,0 +1,56 @@
+{
+ "A non-empty Aether device credential is required.": "必须填写 Aether 设备凭据。",
+ "Aether cloud connection removed. Local control remains enabled.": "已移除 Aether 云端连接,本地控制仍然可用。",
+ "Aether cloud connection saved. Restart the Codex Remote bridge to connect; local control remains available.": "已保存 Aether 云端连接。重启 Codex Remote 桥接后即可连接,本地控制仍然可用。",
+ "Aether cloud relay WebSocket URL": "Aether 云端 relay WebSocket 地址",
+ "Aether pairing completed. Local and cloud control are both active.": "Aether 配对完成,本地与云端控制均已启用。",
+ "Aether pairing was saved, but the cloud connection is currently unavailable. Local control remains active and the cloud connection will retry.": "Aether 配对信息已保存,但当前无法连接云端。本地控制仍然可用,云端连接会继续重试。",
+ "Aether returned an invalid pairing response.": "Aether 返回了无效的配对响应。",
+ "Aether server URL": "Aether 服务器地址",
+ "Attached to the existing Codex conversation. Click to open the web control.": "已附加到现有 Codex 会话,点击打开 Web 控制页。",
+ "Bridge connected. Click to open the web control.": "桥接已连接,点击打开 Web 控制页。",
+ "Bridge paused. Click to open the web control and resume automatically.": "桥接已暂停,点击打开 Web 控制页时会自动恢复。",
+ "Codex Remote Collaboration": "Codex 远程协同",
+ "Codex Remote will attach to {0} after the next bridge start.": "Codex Remote 将在下次启动桥接后附加到 {0}。",
+ "Codex Remote will auto-discover the latest VS Code Codex conversation after the next bridge start.": "Codex Remote 将在下次启动桥接后自动发现最新的 VS Code Codex 会话。",
+ "Connecting to the local Codex collaboration service": "正在连接本地 Codex 协同服务",
+ "Device credential from the Aether pairing flow": "Aether 配对流程生成的设备凭据",
+ "Enter a valid URL.": "请输入有效的 URL。",
+ "Enter a valid WebSocket URL.": "请输入有效的 WebSocket URL。",
+ "Enter the 8-character pairing code.": "请输入 8 位配对码。",
+ "Enter the Aether server URL.": "请输入 Aether 服务器地址。",
+ "Existing Codex conversation ID (leave blank for auto-discovery)": "现有 Codex 会话 ID(留空则自动发现)",
+ "Independent Codex mode is connected. Click to open the web control.": "独立 Codex 模式已连接,点击打开 Web 控制页。",
+ "One-time pairing code shown in Aether": "Aether 中显示的一次性配对码",
+ "Relay access token (leave blank for the local relay)": "Relay 访问 token(本地 relay 请留空)",
+ "Relay token stored in VS Code SecretStorage.": "Relay token 已保存到 VS Code SecretStorage。",
+ "Remote Aether connections must use wss://.": "远程 Aether 连接必须使用 wss://。",
+ "Remote Aether servers must use https://.": "远程 Aether 服务器必须使用 https://。",
+ "Restoring the local collaboration service": "正在恢复本地协同服务",
+ "Send input to the active Codex turn": "向当前 Codex turn 发送输入",
+ "Set codexRemoteCollab.localRelayUrl before starting the bridge.": "请先设置 codexRemoteCollab.localRelayUrl,再启动桥接。",
+ "Start the Codex remote bridge first.": "请先启动 Codex 远程桥接。",
+ "Starting the independent Codex mode.": "正在启动独立 Codex 模式。",
+ "Starting {0}": "正在启动 {0}",
+ "The Codex conversation is not connected": "Codex 会话尚未连接",
+ "The Codex executable is unavailable": "Codex 可执行文件不可用",
+ "The Codex remote bridge attached to the existing VS Code Codex conversation.": "Codex 远程桥接已附加到现有 VS Code Codex 会话。",
+ "The Codex remote bridge is already running.": "Codex 远程桥接已在运行。",
+ "The Codex remote collaboration bridge connected.": "Codex 远程协同桥接已连接。",
+ "The independent Codex mode is not connected": "独立 Codex 模式尚未连接",
+ "The independent Codex remote mode connected.": "独立 Codex 远程模式已连接。",
+ "The bridge is not connected": "桥接尚未连接",
+ "The local collaboration URL is invalid. Check codexRemoteCollab.localRelayUrl.": "本地协同地址无效,请检查 codexRemoteCollab.localRelayUrl。",
+ "The local collaboration service at {0} is temporarily unavailable. The extension will keep retrying.": "本地协同服务 {0} 暂时无法连接,扩展会继续重试。",
+ "The official Codex extension new-conversation command was not found. Make sure the VS Code Codex extension is enabled.": "未找到官方 Codex 扩展的新会话命令,请确认 VS Code Codex 扩展已启用。",
+ "Unable to pair with Aether: {0}": "无法与 Aether 配对:{0}",
+ "Unable to restore the local collaboration service": "无法恢复本地协同服务",
+ "Unable to send Codex input: {0}": "无法发送 Codex 输入:{0}",
+ "Unable to start the Codex remote bridge: {0}": "无法启动 Codex 远程桥接:{0}",
+ "Unable to start the local Codex collaboration service: {0}": "无法启动本地 Codex 协同服务:{0}",
+ "Unable to start the local collaboration service: {0}": "无法启动本地协同服务:{0}",
+ "Use a ws:// or wss:// URL.": "请使用 ws:// 或 wss:// URL。",
+ "Use the Aether origin without credentials, a query, or a fragment.": "请填写不含凭据、查询参数或片段的 Aether 源地址。",
+ "Waiting for a Codex conversation to open in VS Code. It will connect automatically.": "正在等待 VS Code 中打开 Codex 会话,检测到后会自动连接。",
+ "codexRemoteCollab.localRelayUrl must be a loopback ws:// address.": "codexRemoteCollab.localRelayUrl 必须是回环地址上的 ws:// URL。"
+}
diff --git a/aether-vscodex/vscode-extension/package-lock.json b/aether-vscodex/vscode-extension/package-lock.json
new file mode 100644
index 000000000..e746545ed
--- /dev/null
+++ b/aether-vscodex/vscode-extension/package-lock.json
@@ -0,0 +1,94 @@
+{
+ "name": "codex-remote-collab",
+ "version": "0.4.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "codex-remote-collab",
+ "version": "0.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "ws": "^8.18.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.0",
+ "@types/vscode": "^1.85.0",
+ "@types/ws": "^8.5.12",
+ "typescript": "^5.4.5"
+ },
+ "engines": {
+ "vscode": "^1.85.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/vscode": {
+ "version": "1.134.0",
+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.134.0.tgz",
+ "integrity": "sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/aether-vscodex/vscode-extension/package.json b/aether-vscodex/vscode-extension/package.json
new file mode 100644
index 000000000..9cf9e1e7a
--- /dev/null
+++ b/aether-vscodex/vscode-extension/package.json
@@ -0,0 +1,211 @@
+{
+ "name": "codex-remote-collab",
+ "displayName": "%extension.displayName%",
+ "description": "%extension.description%",
+ "version": "0.4.0",
+ "publisher": "local",
+ "license": "MIT",
+ "engines": {
+ "vscode": "^1.85.0"
+ },
+ "categories": [
+ "Other"
+ ],
+ "l10n": "./l10n",
+ "activationEvents": [
+ "onStartupFinished",
+ "onCommand:codexRemoteCollab.openWeb",
+ "onCommand:codexRemoteCollab.start",
+ "onCommand:codexRemoteCollab.stop",
+ "onCommand:codexRemoteCollab.setThreadId",
+ "onCommand:codexRemoteCollab.sendInput",
+ "onCommand:codexRemoteCollab.setRelayToken",
+ "onCommand:codexRemoteCollab.configureCloud",
+ "onCommand:codexRemoteCollab.pairCloud",
+ "onCommand:codexRemoteCollab.snapshot"
+ ],
+ "main": "./dist/extension.js",
+ "contributes": {
+ "commands": [
+ {
+ "command": "codexRemoteCollab.openWeb",
+ "title": "%command.openWeb%"
+ },
+ {
+ "command": "codexRemoteCollab.start",
+ "title": "%command.start%"
+ },
+ {
+ "command": "codexRemoteCollab.stop",
+ "title": "%command.stop%"
+ },
+ {
+ "command": "codexRemoteCollab.setThreadId",
+ "title": "%command.setThreadId%"
+ },
+ {
+ "command": "codexRemoteCollab.sendInput",
+ "title": "%command.sendInput%"
+ },
+ {
+ "command": "codexRemoteCollab.setRelayToken",
+ "title": "%command.setRelayToken%"
+ },
+ {
+ "command": "codexRemoteCollab.configureCloud",
+ "title": "%command.configureCloud%"
+ },
+ {
+ "command": "codexRemoteCollab.pairCloud",
+ "title": "%command.pairCloud%"
+ },
+ {
+ "command": "codexRemoteCollab.snapshot",
+ "title": "%command.snapshot%"
+ }
+ ],
+ "configuration": {
+ "title": "%configuration.title%",
+ "properties": {
+ "codexRemoteCollab.localRelayUrl": {
+ "type": "string",
+ "default": "ws://127.0.0.1:8787/v1/connect",
+ "description": "%configuration.localRelayUrl%"
+ },
+ "codexRemoteCollab.relayUrl": {
+ "type": "string",
+ "default": "ws://127.0.0.1:8787/v1/connect",
+ "description": "%configuration.relayUrl%",
+ "deprecationMessage": "%configuration.relayUrl.deprecation%"
+ },
+ "codexRemoteCollab.cloudRelayUrl": {
+ "type": "string",
+ "default": "",
+ "description": "%configuration.cloudRelayUrl%"
+ },
+ "codexRemoteCollab.aetherUrl": {
+ "type": "string",
+ "default": "",
+ "description": "%configuration.aetherUrl%"
+ },
+ "codexRemoteCollab.autoStart": {
+ "type": "boolean",
+ "default": true,
+ "description": "%configuration.autoStart%"
+ },
+ "codexRemoteCollab.autoStartLocalRelay": {
+ "type": "boolean",
+ "default": true,
+ "description": "%configuration.autoStartLocalRelay%"
+ },
+ "codexRemoteCollab.mode": {
+ "type": "string",
+ "enum": [
+ "attach",
+ "spawn"
+ ],
+ "default": "attach",
+ "description": "%configuration.mode%",
+ "deprecationMessage": "%configuration.mode.deprecation%"
+ },
+ "codexRemoteCollab.controlMode": {
+ "type": "string",
+ "enum": [
+ "sync",
+ "async"
+ ],
+ "enumDescriptions": [
+ "%configuration.controlMode.sync%",
+ "%configuration.controlMode.async%"
+ ],
+ "default": "sync",
+ "description": "%configuration.controlMode%"
+ },
+ "codexRemoteCollab.threadId": {
+ "type": "string",
+ "default": "",
+ "description": "%configuration.threadId%"
+ },
+ "codexRemoteCollab.autoDiscoverThread": {
+ "type": "boolean",
+ "default": true,
+ "description": "%configuration.autoDiscoverThread%"
+ },
+ "codexRemoteCollab.followVscodeSession": {
+ "type": "boolean",
+ "default": true,
+ "description": "%configuration.followVscodeSession%"
+ },
+ "codexRemoteCollab.ipcSocketPath": {
+ "type": "string",
+ "default": "",
+ "description": "%configuration.ipcSocketPath%"
+ },
+ "codexRemoteCollab.hostId": {
+ "type": "string",
+ "default": "local",
+ "description": "%configuration.hostId%"
+ },
+ "codexRemoteCollab.ipcStrictVersions": {
+ "type": "boolean",
+ "default": true,
+ "description": "%configuration.ipcStrictVersions%"
+ },
+ "codexRemoteCollab.codexCommand": {
+ "type": "string",
+ "default": "codex",
+ "description": "%configuration.codexCommand%"
+ },
+ "codexRemoteCollab.codexArgs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [
+ "app-server",
+ "--stdio"
+ ],
+ "description": "%configuration.codexArgs%"
+ },
+ "codexRemoteCollab.defaultCwd": {
+ "type": "string",
+ "default": "",
+ "description": "%configuration.defaultCwd%"
+ },
+ "codexRemoteCollab.approvalTimeoutMs": {
+ "type": "number",
+ "default": 300000,
+ "minimum": 1000,
+ "description": "%configuration.approvalTimeoutMs%"
+ },
+ "codexRemoteCollab.allowHighRiskApprovals": {
+ "type": "boolean",
+ "default": false,
+ "description": "%configuration.allowHighRiskApprovals%"
+ },
+ "codexRemoteCollab.relayReconnect": {
+ "type": "boolean",
+ "default": true,
+ "description": "%configuration.relayReconnect%"
+ }
+ }
+ }
+ },
+ "scripts": {
+ "vscode:prepublish": "npm run build:web && npm run build",
+ "build:web": "npm --prefix ../web run build",
+ "build": "tsc -p tsconfig.json && node scripts/sync-local-relay.cjs",
+ "compile": "npm run build",
+ "check": "tsc --noEmit -p tsconfig.json",
+ "start:stdio": "node dist/cli.js"
+ },
+ "dependencies": {
+ "ws": "^8.18.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.0",
+ "@types/vscode": "^1.85.0",
+ "@types/ws": "^8.5.12",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/aether-vscodex/vscode-extension/package.nls.json b/aether-vscodex/vscode-extension/package.nls.json
new file mode 100644
index 000000000..03c11081e
--- /dev/null
+++ b/aether-vscodex/vscode-extension/package.nls.json
@@ -0,0 +1,38 @@
+{
+ "extension.displayName": "Codex Remote Collaboration",
+ "extension.description": "Synchronize the current VS Code Codex conversation or manage independent Codex conversations from a local browser and Aether cloud.",
+ "command.openWeb": "Codex Remote: Open Local Web Console",
+ "command.start": "Codex Remote: Start Bridge",
+ "command.stop": "Codex Remote: Stop Bridge",
+ "command.setThreadId": "Codex Remote: Set Existing Thread ID",
+ "command.sendInput": "Codex Remote: Send Input",
+ "command.setRelayToken": "Codex Remote: Set Local Relay Token",
+ "command.configureCloud": "Codex Remote: Configure Aether Cloud Manually",
+ "command.pairCloud": "Codex Remote: Pair with Aether",
+ "command.snapshot": "Codex Remote: Show Snapshot",
+ "configuration.title": "Codex Remote Collaboration",
+ "configuration.localRelayUrl": "Loopback relay used by the local browser UI. It remains active when Aether cloud sync is enabled.",
+ "configuration.relayUrl": "Legacy relay setting retained for compatibility. Use localRelayUrl and cloudRelayUrl for new installations.",
+ "configuration.relayUrl.deprecation": "Use codexRemoteCollab.localRelayUrl for local access and codexRemoteCollab.cloudRelayUrl for Aether cloud access.",
+ "configuration.cloudRelayUrl": "Optional Aether cloud relay WebSocket URL. The device credential is stored separately in VS Code SecretStorage.",
+ "configuration.aetherUrl": "Aether server origin used by the one-time pairing flow.",
+ "configuration.autoStart": "Start the bridge when the extension activates.",
+ "configuration.autoStartLocalRelay": "Automatically host the bundled relay for loopback ws:// URLs.",
+ "configuration.mode": "Attach to the existing official VS Code Codex session, or spawn a separate app-server for legacy use.",
+ "configuration.mode.deprecation": "Use codexRemoteCollab.controlMode. attach maps to sync and spawn maps to async.",
+ "configuration.controlMode": "Choose whether the web console follows the current VS Code Codex conversation or manages independent conversations.",
+ "configuration.controlMode.sync": "Synchronize with the conversation currently shown in the official VS Code Codex panel.",
+ "configuration.controlMode.async": "Run an independent Codex app-server and manage its conversations from the web console.",
+ "configuration.threadId": "Existing VS Code Codex conversation ID to follow. Empty uses the most recent locally available session.",
+ "configuration.autoDiscoverThread": "Discover a recent VS Code Codex conversation when no thread ID is configured.",
+ "configuration.followVscodeSession": "Follow conversation changes in the attached official VS Code Codex panel.",
+ "configuration.ipcSocketPath": "Optional official Codex IPC socket path. Empty uses CODEX_HOME/ipc/ipc.sock.",
+ "configuration.hostId": "Codex host identifier used for existing-session discovery.",
+ "configuration.ipcStrictVersions": "Reject unknown private IPC stream versions instead of applying them optimistically.",
+ "configuration.codexCommand": "Asynchronous mode: Codex executable used to launch the independent app-server.",
+ "configuration.codexArgs": "Asynchronous mode: arguments passed to the Codex executable.",
+ "configuration.defaultCwd": "Asynchronous mode: working directory used when starting a conversation.",
+ "configuration.approvalTimeoutMs": "Milliseconds before an unanswered Codex approval or input request is denied locally.",
+ "configuration.allowHighRiskApprovals": "Allow the remote operator to approve high-risk commands. Keep disabled unless the relay and host are tightly controlled.",
+ "configuration.relayReconnect": "Reconnect outbound relay WebSockets after a disconnect."
+}
diff --git a/aether-vscodex/vscode-extension/package.nls.zh-cn.json b/aether-vscodex/vscode-extension/package.nls.zh-cn.json
new file mode 100644
index 000000000..58314d4f0
--- /dev/null
+++ b/aether-vscodex/vscode-extension/package.nls.zh-cn.json
@@ -0,0 +1,38 @@
+{
+ "extension.displayName": "Codex 远程协同",
+ "extension.description": "从本地浏览器或 Aether 云端同步 VS Code 当前 Codex 会话,或独立管理 Codex 会话。",
+ "command.openWeb": "Codex 远程:打开本地 Web 控制台",
+ "command.start": "Codex 远程:启动桥接",
+ "command.stop": "Codex 远程:停止桥接",
+ "command.setThreadId": "Codex 远程:设置现有会话 ID",
+ "command.sendInput": "Codex 远程:发送输入",
+ "command.setRelayToken": "Codex 远程:设置本地中继令牌",
+ "command.configureCloud": "Codex 远程:手动配置 Aether 云端",
+ "command.pairCloud": "Codex 远程:与 Aether 配对",
+ "command.snapshot": "Codex 远程:显示会话快照",
+ "configuration.title": "Codex 远程协同",
+ "configuration.localRelayUrl": "本地浏览器控制台使用的回环中继地址。启用 Aether 云同步后仍保持连接。",
+ "configuration.relayUrl": "为兼容旧版本保留的中继设置。新安装请使用 localRelayUrl 和 cloudRelayUrl。",
+ "configuration.relayUrl.deprecation": "本地访问请使用 codexRemoteCollab.localRelayUrl,Aether 云端访问请使用 codexRemoteCollab.cloudRelayUrl。",
+ "configuration.cloudRelayUrl": "可选的 Aether 云端 WebSocket 中继地址。设备凭据单独保存在 VS Code SecretStorage 中。",
+ "configuration.aetherUrl": "一次性配对流程使用的 Aether 服务地址。",
+ "configuration.autoStart": "扩展激活时自动启动桥接。",
+ "configuration.autoStartLocalRelay": "为回环 ws:// 地址自动启动扩展内置的本地中继。",
+ "configuration.mode": "附加到官方 VS Code Codex 现有会话,或为兼容旧版本启动独立 app-server。",
+ "configuration.mode.deprecation": "请改用 codexRemoteCollab.controlMode。attach 对应 sync,spawn 对应 async。",
+ "configuration.controlMode": "选择 Web 控制台是跟随 VS Code 当前 Codex 会话,还是独立管理会话。",
+ "configuration.controlMode.sync": "同步展示官方 VS Code Codex 面板当前打开的会话。",
+ "configuration.controlMode.async": "启动独立 Codex app-server,并从 Web 控制台管理其会话。",
+ "configuration.threadId": "要跟随的现有 VS Code Codex 会话 ID。留空时使用本机最近可附加的会话。",
+ "configuration.autoDiscoverThread": "未设置会话 ID 时自动发现最近的 VS Code Codex 会话。",
+ "configuration.followVscodeSession": "自动跟随官方 VS Code Codex 面板中的会话切换。",
+ "configuration.ipcSocketPath": "可选的官方 Codex IPC socket 路径。留空时使用 CODEX_HOME/ipc/ipc.sock。",
+ "configuration.hostId": "现有会话发现使用的 Codex 主机标识。",
+ "configuration.ipcStrictVersions": "拒绝未知的私有 IPC 流版本,不进行乐观兼容。",
+ "configuration.codexCommand": "异步模式:用于启动独立 app-server 的 Codex 可执行文件。",
+ "configuration.codexArgs": "异步模式:传给 Codex 可执行文件的参数。",
+ "configuration.defaultCwd": "异步模式:启动会话时使用的工作目录。",
+ "configuration.approvalTimeoutMs": "Codex 授权或输入请求无人处理时,在本地拒绝前等待的毫秒数。",
+ "configuration.allowHighRiskApprovals": "允许远程操作员批准高风险命令。仅在中继和主机均受严格控制时启用。",
+ "configuration.relayReconnect": "中继 WebSocket 断开后自动重连。"
+}
diff --git a/aether-vscodex/vscode-extension/scripts/sync-local-relay.cjs b/aether-vscodex/vscode-extension/scripts/sync-local-relay.cjs
new file mode 100644
index 000000000..a4db0a17a
--- /dev/null
+++ b/aether-vscodex/vscode-extension/scripts/sync-local-relay.cjs
@@ -0,0 +1,19 @@
+const fs = require("node:fs");
+const path = require("node:path");
+
+const extensionRoot = path.resolve(__dirname, "..");
+const projectRoot = path.resolve(extensionRoot, "..");
+const outputRoot = path.join(extensionRoot, "dist", "local-relay");
+const publicRoot = path.join(extensionRoot, "dist", "public");
+const vuePublicRoot = path.join(projectRoot, "web", "dist");
+
+if (!fs.existsSync(path.join(vuePublicRoot, "index.html"))) {
+ throw new Error("web/dist is missing; run npm run build:web before building the extension");
+}
+
+fs.rmSync(outputRoot, { recursive: true, force: true });
+fs.rmSync(publicRoot, { recursive: true, force: true });
+fs.mkdirSync(outputRoot, { recursive: true });
+fs.mkdirSync(publicRoot, { recursive: true });
+fs.copyFileSync(path.join(projectRoot, "relay", "server.js"), path.join(outputRoot, "server.js"));
+fs.cpSync(vuePublicRoot, publicRoot, { recursive: true });
diff --git a/aether-vscodex/vscode-extension/src/bridge.ts b/aether-vscodex/vscode-extension/src/bridge.ts
new file mode 100644
index 000000000..9f6050499
--- /dev/null
+++ b/aether-vscodex/vscode-extension/src/bridge.ts
@@ -0,0 +1,46 @@
+import { CodexAgentAdapter, CodexAgentAdapterOptions } from "./codexAgentAdapter";
+import { RelayClient, RelayClientOptions } from "./relayClient";
+import { RelayHost, RelayHostOptions } from "./relayHost";
+import { AgentAdapter, Logger, RelayTransport } from "./protocol";
+
+export interface CodexRemoteBridgeOptions {
+ /** Use a supplied adapter/transport when embedding or testing. */
+ adapter?: AgentAdapter;
+ relay?: RelayTransport;
+ adapterOptions?: CodexAgentAdapterOptions;
+ relayOptions?: RelayClientOptions;
+ sessionId?: string;
+ capabilities?: Iterable;
+ logger?: Logger;
+}
+export interface CodexRemoteBridge {
+ adapter: AgentAdapter;
+ relay: RelayTransport;
+ host: RelayHost;
+ start(): Promise;
+ stop(): Promise;
+}
+
+/** Construct the default outbound VS Code bridge in one call. */
+export function createBridge(options: CodexRemoteBridgeOptions): CodexRemoteBridge {
+ const adapter = options.adapter ?? new CodexAgentAdapter(options.adapterOptions);
+ const relay = options.relay ?? (() => {
+ if (!options.relayOptions) throw new Error("relayOptions are required when no relay transport is supplied");
+ return new RelayClient(options.relayOptions);
+ })();
+ const hostOptions: RelayHostOptions = {
+ adapter,
+ relay,
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
+ ...(options.capabilities ? { capabilities: options.capabilities } : {}),
+ ...(options.logger ? { logger: options.logger } : {}),
+ };
+ const host = new RelayHost(hostOptions);
+ return {
+ adapter,
+ relay,
+ host,
+ start: () => host.start(),
+ stop: () => host.stop(),
+ };
+}
diff --git a/aether-vscodex/vscode-extension/src/cli.ts b/aether-vscodex/vscode-extension/src/cli.ts
new file mode 100644
index 000000000..511b7defe
--- /dev/null
+++ b/aether-vscodex/vscode-extension/src/cli.ts
@@ -0,0 +1,30 @@
+import { CodexAgentAdapter } from "./codexAgentAdapter";
+import { RelayHost } from "./relayHost";
+import { StdioRelayTransport } from "./relayClient";
+
+/** Standalone bridge: relay frames in stdin, relay frames out on stdout. */
+async function main(): Promise {
+ const logger = {
+ debug: (message: string, ...args: unknown[]) => console.error(`[debug] ${message}`, ...args),
+ info: (message: string, ...args: unknown[]) => console.error(`[info] ${message}`, ...args),
+ warn: (message: string, ...args: unknown[]) => console.error(`[warn] ${message}`, ...args),
+ error: (message: string, ...args: unknown[]) => console.error(`[error] ${message}`, ...args),
+ };
+ const command = process.env.CODEX_COMMAND || "codex";
+ const args = process.env.CODEX_APP_SERVER_ARGS ? JSON.parse(process.env.CODEX_APP_SERVER_ARGS) as string[] : ["app-server", "--stdio"];
+ const adapter = new CodexAgentAdapter({ command, args, defaultCwd: process.env.CODEX_WORKSPACE, logger });
+ const relay = new StdioRelayTransport(process.stdin, process.stdout, logger);
+ const host = new RelayHost({ adapter, relay, sendHandshake: true, logger });
+ const shutdown = async (): Promise => {
+ await host.stop();
+ process.exit(0);
+ };
+ process.once("SIGINT", () => void shutdown());
+ process.once("SIGTERM", () => void shutdown());
+ await host.start();
+}
+
+void main().catch((error) => {
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/aether-vscodex/vscode-extension/src/codexAgentAdapter.ts b/aether-vscodex/vscode-extension/src/codexAgentAdapter.ts
new file mode 100644
index 000000000..56a3fff80
--- /dev/null
+++ b/aether-vscodex/vscode-extension/src/codexAgentAdapter.ts
@@ -0,0 +1,1914 @@
+import { createHash } from "node:crypto";
+
+import {
+ AgentAdapter,
+ AgentEvent,
+ asJsonObject,
+ asJsonValue,
+ approvalDecisionKindForMethod,
+ Disposable,
+ hasApprovalDecisionField,
+ isRecord,
+ JsonObject,
+ JsonRpcId,
+ JsonRpcRequest,
+ JsonValue,
+ Logger,
+ PendingApproval,
+ SessionSnapshot,
+ isJsonRpcId,
+ jsonRpcIdKey,
+} from "./protocol";
+import { JsonlRpcClient, JsonlRpcClientOptions } from "./jsonlRpc";
+
+const APPROVAL_METHODS = new Set([
+ "item/commandExecution/requestApproval",
+ "item/fileChange/requestApproval",
+ "item/permissions/requestApproval",
+ "applyPatchApproval",
+ "execCommandApproval",
+]);
+
+const INPUT_REQUEST_METHODS = new Set([
+ "item/tool/requestUserInput",
+ "mcpServer/elicitation/request",
+]);
+
+// Paginated threads can contain many thousands of items. Keep hydration
+// bounded so one history request cannot exhaust the extension host or exceed
+// the relay frame limit, while still covering normal long-running sessions.
+const HISTORY_PAGE_SIZE = 100;
+const MAX_HISTORY_TURN_PAGES = 100;
+const MAX_HISTORY_TURNS = HISTORY_PAGE_SIZE * MAX_HISTORY_TURN_PAGES;
+const MAX_HISTORY_ITEM_PAGES = 100;
+
+export interface CodexAgentAdapterOptions extends JsonlRpcClientOptions {
+ clientName?: string;
+ clientTitle?: string | null;
+ clientVersion?: string;
+ initializeCapabilities?: JsonObject;
+ defaultCwd?: string;
+ maxOutputTailChars?: number;
+ approvalTimeoutMs?: number;
+ /** Handle non-approval server requests (auth refresh, tool calls, etc.). */
+ onServerRequest?: (request: JsonRpcRequest) => Promise;
+ autoRejectUnsupportedRequests?: boolean;
+}
+
+interface PendingRequest {
+ request: JsonRpcRequest;
+ approval?: PendingApproval;
+ timer?: NodeJS.Timeout;
+}
+
+/**
+ * AgentAdapter implementation backed by a child `codex app-server --stdio`.
+ *
+ * It deliberately does not shell out for individual tasks. All task and
+ * approval operations go through the app-server JSON-RPC channel.
+ */
+export class CodexAgentAdapter implements AgentAdapter {
+ readonly rpc: JsonlRpcClient;
+ private readonly options: Required<
+ Pick
+ > &
+ Omit;
+ private readonly listeners = new Set<(event: AgentEvent) => void>();
+ private readonly pending = new Map();
+ private threadId: string | null = null;
+ private turnId: string | null = null;
+ private state = "disconnected";
+ private outputTail = "";
+ private messages: JsonValue[] = [];
+ private sessionMetadata: JsonObject = {};
+ private availableModels: JsonValue[] = [];
+ private historyComplete = true;
+ private sessionSwitching = false;
+ private started = false;
+ private readonly rpcDisposables: Disposable[];
+
+ constructor(options: CodexAgentAdapterOptions = {}, rpc?: JsonlRpcClient) {
+ this.options = {
+ clientName: options.clientName ?? "codex-remote-collab",
+ clientVersion: options.clientVersion ?? "0.4.0",
+ maxOutputTailChars: options.maxOutputTailChars ?? 32_000,
+ approvalTimeoutMs: options.approvalTimeoutMs ?? 5 * 60_000,
+ ...options,
+ };
+ this.rpc = rpc ?? new JsonlRpcClient(options);
+ this.rpcDisposables = [
+ this.rpc.onNotification((message) => this.handleNotification(message.method, message.params)),
+ this.rpc.onServerRequest((request) => this.handleServerRequest(request)),
+ this.rpc.onExit((error) => {
+ this.started = false;
+ this.state = "disconnected";
+ // A new app-server process cannot safely reuse ids from the dead
+ // process. Clear them before publishing the terminal event so a
+ // reconnect/restart cannot steer or interrupt a stale turn.
+ this.threadId = null;
+ this.turnId = null;
+ this.outputTail = "";
+ this.messages = [];
+ this.sessionMetadata = {};
+ this.historyComplete = true;
+ this.sessionSwitching = false;
+ // The child cannot receive a response after exit. Drop every pending
+ // approval/input and publish an explicit expiry so the relay removes
+ // its corresponding request instead of retaining a stale request id.
+ this.dropPendingRequests(error?.message ?? "app-server exited");
+ this.emit({ type: "connection.closed", payload: error ? { message: error.message } : {} });
+ }),
+ ];
+ }
+
+ async start(): Promise {
+ if (this.started) return;
+ await this.rpc.start();
+ this.state = "initializing";
+ const capabilities = {
+ experimentalApi: true,
+ requestAttestation: false,
+ ...(this.options.initializeCapabilities ?? {}),
+ };
+ await this.rpc.request("initialize", {
+ clientInfo: {
+ name: this.options.clientName,
+ title: this.options.clientTitle ?? null,
+ version: this.options.clientVersion,
+ },
+ capabilities,
+ });
+ this.rpc.notify("initialized");
+ this.started = true;
+ this.state = "idle";
+ await this.refreshAvailableModels();
+ this.emit({ type: "connection.opened", payload: {} });
+ }
+
+ async startThread(params: JsonObject = {}): Promise {
+ this.ensureStarted();
+ this.ensureSessionChangeAllowed();
+ const requestParams: JsonObject = { ...params };
+ if (requestParams.cwd === undefined && this.options.defaultCwd) {
+ requestParams.cwd = this.options.defaultCwd;
+ }
+ const result = await this.rpc.request("thread/start", requestParams);
+ this.historyComplete = true;
+ this.commitThreadResult(result, true);
+ await this.publishHistorySnapshot();
+ return result;
+ }
+
+ async newSession(params: JsonObject = {}): Promise {
+ return this.startThread(params);
+ }
+
+ async listSessions(params: JsonObject = {}): Promise {
+ this.ensureStarted();
+ const result = await this.rpc.request("thread/list", normalizeThreadListParams(params));
+ const threads = isRecord(result) && Array.isArray(result.data)
+ ? result.data.filter(isRecord).map((thread) => asJsonObject(thread))
+ : [];
+ const sessions = threads.map((thread) => {
+ const id = typeof thread.id === "string" ? thread.id : "";
+ const preview = typeof thread.preview === "string" ? thread.preview : "";
+ const name = typeof thread.name === "string" ? thread.name : "";
+ const cwd = typeof thread.cwd === "string" ? redactText(thread.cwd) : undefined;
+ const updatedAt = finiteNumber(thread.updatedAt) ?? finiteNumber(thread.createdAt);
+ return {
+ threadId: id,
+ title: sessionTitle(name || preview, id),
+ updatedAtMs: updatedAt === undefined ? null : Math.round(updatedAt * 1_000),
+ ...(cwd ? { cwd } : {}),
+ active: id === this.threadId,
+ available: Boolean(id),
+ ...(thread.status !== undefined ? { status: redactJson(thread.status) } : {}),
+ ...(thread.source !== undefined ? { source: redactJson(thread.source) } : {}),
+ };
+ }).filter((session) => session.threadId);
+ return asJsonValue({
+ sessions,
+ activeThreadId: this.threadId,
+ nextCursor: isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : null,
+ backwardsCursor: isRecord(result) && typeof result.backwardsCursor === "string" ? result.backwardsCursor : null,
+ });
+ }
+
+ async selectSession(params: JsonObject): Promise {
+ this.ensureStarted();
+ const target = (this.stringParam(params, "threadId") ?? this.stringParam(params, "conversationId"))?.trim();
+ if (!target) throw new Error("session/select requires threadId");
+ this.ensureSessionChangeAllowed();
+ if (this.sessionSwitching) throw new Error("a session switch is already in progress");
+
+ const previousThreadId = this.threadId;
+ const previousState = this.state;
+ this.sessionSwitching = true;
+ this.state = "syncing";
+ this.historyComplete = false;
+ this.emit({
+ type: "session.switching",
+ threadId: target,
+ payload: { previousThreadId, targetThreadId: target },
+ });
+ try {
+ const resumeResult = await this.resumeThreadForSelection(target);
+ const hydrated = await this.ensureThreadHistory(resumeResult);
+ this.commitThreadResult(resumeResult, true, hydrated);
+ const switched = previousThreadId !== target;
+ this.emit({
+ type: "session.selected",
+ threadId: target,
+ payload: { previousThreadId, threadId: target, switched, available: true },
+ });
+ await this.publishHistorySnapshot();
+ return asJsonValue({
+ threadId: target,
+ previousThreadId,
+ switched,
+ available: true,
+ result: redactJson(resumeResult),
+ });
+ } catch (error) {
+ this.state = previousState;
+ this.historyComplete = true;
+ throw error;
+ } finally {
+ this.sessionSwitching = false;
+ }
+ }
+
+ async updateThreadSettings(params: JsonObject): Promise {
+ this.ensureStarted();
+ const threadId = this.stringParam(params, "threadId") ?? this.threadId;
+ if (!threadId) throw new Error("thread/settings/update requires threadId");
+ if (threadId !== this.threadId) throw new Error("thread/settings/update can only target the selected thread");
+ const settings = normalizeThreadSettings(params);
+ const result = await this.rpc.request("thread/settings/update", { threadId, ...settings.wire });
+ this.mergeThreadSettings(settings.display);
+ await this.publishAuthoritativeSnapshot();
+ return result;
+ }
+
+ async startTurn(params: JsonObject): Promise {
+ this.ensureStarted();
+ const requestParams = this.normalizeTurnParams(params);
+ const result = await this.rpc.request("turn/start", requestParams);
+ const turn = isRecord(result) && isRecord(result.turn) ? result.turn : undefined;
+ const nextTurnId = turn && typeof turn.id === "string"
+ ? turn.id
+ : isRecord(result) && typeof result.turnId === "string"
+ ? result.turnId
+ : undefined;
+ if (nextTurnId) this.turnId = nextTurnId;
+ if (typeof requestParams.threadId === "string") this.threadId = requestParams.threadId;
+ this.state = "active";
+ return result;
+ }
+
+ async steerTurn(params: JsonObject): Promise {
+ this.ensureStarted();
+ const requestParams = this.normalizeTurnParams(params, true);
+ const result = await this.rpc.request("turn/steer", requestParams);
+ const nextTurnId = isRecord(result) && typeof result.turnId === "string"
+ ? result.turnId
+ : isRecord(result) && isRecord(result.turn) && typeof result.turn.id === "string"
+ ? result.turn.id
+ : undefined;
+ if (nextTurnId) this.turnId = nextTurnId;
+ if (typeof requestParams.threadId === "string") this.threadId = requestParams.threadId;
+ this.state = "active";
+ return result;
+ }
+
+ async interruptTurn(params: JsonObject): Promise {
+ this.ensureStarted();
+ const threadId = this.stringParam(params, "threadId") ?? this.threadId;
+ const turnId = this.stringParam(params, "turnId") ?? this.turnId;
+ if (!threadId || !turnId) throw new Error("turn/interrupt requires threadId and turnId");
+ const result = await this.rpc.request("turn/interrupt", { threadId, turnId });
+ this.state = "idle";
+ // Do this eagerly rather than waiting for the asynchronous
+ // `turn/completed` notification. A caller may submit the next turn as
+ // soon as the interrupt response resolves.
+ this.turnId = null;
+ return result;
+ }
+
+ async sendInput(text: string, params: JsonObject = {}): Promise {
+ const body: JsonObject = { ...params, text };
+ if (this.turnId) return this.steerTurn({ ...body, expectedTurnId: this.turnId });
+ return this.startTurn(body);
+ }
+
+ async cancel(taskId?: string, params: JsonObject = {}): Promise {
+ return this.interruptTurn({ ...params, ...(taskId ? { turnId: taskId } : {}) });
+ }
+
+ async respondApproval(
+ requestId: JsonRpcId,
+ decision: "allow" | "deny" | "cancel",
+ reason?: string,
+ response?: JsonValue,
+ ): Promise {
+ this.ensureStarted();
+ const key = jsonRpcIdKey(requestId);
+ const pending = this.pending.get(key);
+ if (!pending) throw new Error(`unknown or already resolved approval request: ${key}`);
+ const rawResponse = response === undefined
+ ? this.defaultApprovalResponse(pending.request.method, pending.request.params, decision, reason)
+ : asJsonValue(response);
+ validateAdapterResponse(pending.request.method, rawResponse, decision);
+ const result = normalizeServerResponse(pending.request.method, rawResponse);
+ if (pending.timer) clearTimeout(pending.timer);
+ this.pending.delete(key);
+ try {
+ this.rpc.respond(pending.request.id, result);
+ } catch (error) {
+ // Do not leave a request retryable forever when the child exits between
+ // the liveness check and the JSON-RPC write.
+ this.emit({
+ type: pending.approval ? "approval.expired" : "input.expired",
+ threadId: pending.approval?.threadId,
+ turnId: pending.approval?.turnId,
+ requestId,
+ payload: { requestId: asJsonValue(requestId), reason: "app-server unavailable" },
+ });
+ throw error;
+ }
+ this.emit({
+ type: pending.approval ? "approval.resolved" : "input.resolved",
+ threadId: pending.approval?.threadId,
+ turnId: pending.approval?.turnId,
+ requestId,
+ payload: {
+ requestId: asJsonValue(requestId),
+ decision,
+ ...(reason ? { reason } : {}),
+ },
+ });
+ return result;
+ }
+
+ async denyPending(reason = "relay disconnected"): Promise {
+ const pendingIds = [...this.pending.values()].map((entry) => entry.request.id);
+ for (const requestId of pendingIds) {
+ try {
+ await this.respondApproval(requestId, "deny", reason);
+ } catch {
+ // The app-server may have resolved or exited between the snapshot and
+ // this fail-closed cleanup pass.
+ }
+ }
+ }
+
+ async snapshot(): Promise {
+ const status = this.statusSnapshot();
+ return {
+ threadId: this.threadId,
+ turnId: this.turnId,
+ state: this.state,
+ pendingApprovals: [...this.pending.values()]
+ .map((entry) => entry.approval)
+ .filter((approval): approval is PendingApproval => Boolean(approval)),
+ pendingRequests: [...this.pending.values()].map((entry) => ({
+ requestId: entry.request.id,
+ method: entry.request.method,
+ params: redactJson(asJsonObject(entry.request.params)),
+ ...(entry.approval?.commandHash ? { commandHash: entry.approval.commandHash } : {}),
+ ...(entry.approval?.risk ? { risk: entry.approval.risk } : {}),
+ ...(entry.approval?.summary ? { summary: entry.approval.summary } : {}),
+ ...(entry.approval?.createdAt ? { createdAt: entry.approval.createdAt } : {}),
+ ...(entry.approval?.expiresAt ? { expiresAt: entry.approval.expiresAt } : {}),
+ })),
+ outputTail: this.outputTail,
+ messages: this.messages.map((message) => asJsonValue(message)),
+ status,
+ activity: status.activity,
+ turnStatus: status.turnStatus,
+ activeFlags: [...status.activeFlags],
+ startedAtMs: status.startedAtMs,
+ durationMs: status.durationMs,
+ elapsedMs: status.elapsedMs,
+ metadata: {
+ adapter: "codex-app-server",
+ mode: "async",
+ started: this.started,
+ historyComplete: this.historyComplete,
+ ...this.sessionMetadata,
+ availableModels: this.availableModels.map((model) => asJsonValue(model)),
+ models: this.availableModels.map((model) => asJsonValue(model)),
+ },
+ };
+ }
+
+ onEvent(listener: (event: AgentEvent) => void): Disposable {
+ this.listeners.add(listener);
+ return { dispose: () => this.listeners.delete(listener) };
+ }
+
+ async dispose(): Promise {
+ for (const [key, entry] of this.pending) {
+ if (entry.timer) clearTimeout(entry.timer);
+ // A disconnected host must never leave a command approval hanging.
+ try {
+ this.rpc.respond(entry.request.id, normalizeServerResponse(
+ entry.request.method,
+ this.defaultApprovalResponse(entry.request.method, entry.request.params, "deny", "bridge stopped"),
+ ));
+ } catch {
+ // The child may already have exited.
+ }
+ this.pending.delete(key);
+ }
+ for (const disposable of this.rpcDisposables) disposable.dispose();
+ this.rpc.close();
+ this.started = false;
+ this.state = "disconnected";
+ this.threadId = null;
+ this.turnId = null;
+ this.messages = [];
+ this.outputTail = "";
+ this.sessionMetadata = {};
+ this.historyComplete = true;
+ this.sessionSwitching = false;
+ }
+
+ private ensureStarted(): void {
+ if (!this.started || !this.rpc.running) throw new Error("Codex app-server is not started");
+ }
+
+ private ensureSessionChangeAllowed(): void {
+ if (this.turnId || this.pending.size) {
+ throw new Error("cannot change sessions while a turn or approval is active");
+ }
+ }
+
+ private async refreshAvailableModels(): Promise {
+ try {
+ const models: JsonValue[] = [];
+ let cursor: string | null = null;
+ for (let page = 0; page < 10; page += 1) {
+ const result = await this.rpc.request("model/list", {
+ limit: 100,
+ includeHidden: false,
+ ...(cursor ? { cursor } : {}),
+ });
+ if (!isRecord(result)) break;
+ if (Array.isArray(result.data)) {
+ for (const model of result.data) {
+ if (isRecord(model) && typeof model.model === "string") models.push(redactJson(model));
+ }
+ }
+ cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : null;
+ if (!cursor) break;
+ }
+ this.availableModels = models;
+ } catch (error) {
+ // Older app-server builds may not expose the model catalog. Thread and
+ // turn control should remain usable with a manually supplied model.
+ this.options.logger?.debug?.("Unable to load app-server model catalog", error);
+ }
+ }
+
+ private async resumeThreadForSelection(threadId: string): Promise {
+ try {
+ // Paginated history is the stable protocol for newer app-server builds.
+ // Request the first page in chronological order so the renderer can use
+ // one consistent ordering while older pages are appended.
+ return await this.rpc.request("thread/resume", {
+ threadId,
+ excludeTurns: true,
+ initialTurnsPage: {
+ limit: HISTORY_PAGE_SIZE,
+ sortDirection: "asc",
+ itemsView: "full",
+ },
+ });
+ } catch (error) {
+ if (isPaginationUnsupportedError(error)) {
+ // Older app-server versions reject the pagination fields. Retry with
+ // the legacy full-history shape before giving up.
+ try {
+ return await this.rpc.request("thread/resume", { threadId, excludeTurns: false });
+ } catch (legacyError) {
+ if (!isActiveWriterError(legacyError)) throw legacyError;
+ return this.readThreadMetadata(threadId, legacyError);
+ }
+ }
+ if (isActiveWriterError(error)) {
+ // A thread currently owned by another app-server cannot be resumed by
+ // this process, but its metadata and paginated history are still
+ // readable. Keep the conversation view available and report the
+ // writer limitation through metadata rather than showing an empty
+ // session after a successful list click.
+ return this.readThreadMetadata(threadId, error);
+ }
+ throw error;
+ }
+ }
+
+ private async readThreadMetadata(threadId: string, originalError: unknown): Promise {
+ try {
+ return await this.rpc.request("thread/read", { threadId, includeTurns: false });
+ } catch (error) {
+ this.options.logger?.debug?.("Unable to read a thread after resume failed", error);
+ throw originalError instanceof Error ? originalError : error;
+ }
+ }
+
+ private async ensureThreadHistory(result: JsonValue): Promise {
+ const response = isRecord(result) ? result : {};
+ const thread = extractThread(result);
+ if (!thread) throw new Error("thread/resume returned no thread");
+ const turns = Array.isArray(thread.turns) ? thread.turns : undefined;
+ const threadId = typeof thread.id === "string" ? thread.id : undefined;
+ if (!threadId) throw new Error("thread/resume returned a thread without id");
+
+ const paginated = thread.historyMode === "paginated" || isRecord(response.initialTurnsPage);
+ const hasHistoryEvidence = Boolean(
+ (typeof thread.preview === "string" && thread.preview.trim())
+ || ((finiteNumber(thread.updatedAt) ?? 0) > (finiteNumber(thread.createdAt) ?? 0)),
+ );
+ if (!paginated && turns && (turns.length > 0 || !hasHistoryEvidence)) {
+ this.historyComplete = true;
+ return thread;
+ }
+
+ if (paginated) {
+ try {
+ const initialPage = isRecord(response.initialTurnsPage)
+ ? response.initialTurnsPage
+ : undefined;
+ const hydratedTurns = await this.loadPaginatedTurns(threadId, initialPage);
+ return { ...thread, turns: hydratedTurns };
+ } catch (error) {
+ // Some transitional server builds advertise paginated threads but do
+ // not implement one of the page methods. Fall back to the legacy read
+ // endpoint so the session remains usable instead of appearing blank.
+ this.options.logger?.debug?.("Paginated thread hydration failed; trying thread/read", error);
+ try {
+ const readResult = await this.rpc.request("thread/read", { threadId, includeTurns: true });
+ const hydrated = extractThread(readResult);
+ if (hydrated) {
+ this.historyComplete = true;
+ return hydrated;
+ }
+ } catch (readError) {
+ this.options.logger?.debug?.("Legacy thread/read fallback failed", readError);
+ }
+ this.historyComplete = false;
+ return { ...thread, turns: turns ?? [] };
+ }
+ }
+
+ if (turns && turns.length > 0) {
+ this.historyComplete = true;
+ return thread;
+ }
+
+ const readResult = await this.rpc.request("thread/read", { threadId, includeTurns: true });
+ const hydrated = extractThread(readResult);
+ if (!hydrated) throw new Error("thread/read returned no thread");
+ this.historyComplete = true;
+ return hydrated;
+ }
+
+ private async loadPaginatedTurns(threadId: string, initialPage?: JsonObject): Promise {
+ const byId = new Map();
+ let page: JsonObject | undefined = initialPage;
+ let cursor: string | null = null;
+ let complete = true;
+
+ for (let index = 0; index < MAX_HISTORY_TURN_PAGES; index += 1) {
+ if (!page) {
+ const response = await this.rpc.request("thread/turns/list", {
+ threadId,
+ limit: HISTORY_PAGE_SIZE,
+ sortDirection: "asc",
+ itemsView: "full",
+ ...(cursor ? { cursor } : {}),
+ });
+ page = isRecord(response) ? response : {};
+ }
+
+ const pageData = Array.isArray(page.data) ? page.data : [];
+ for (const value of pageData) {
+ if (!isRecord(value)) continue;
+ const id = typeof value.id === "string" ? value.id : `turn-${byId.size}`;
+ byId.set(id, { ...value });
+ if (byId.size >= MAX_HISTORY_TURNS) {
+ complete = false;
+ break;
+ }
+ }
+ if (byId.size >= MAX_HISTORY_TURNS) break;
+ const next = typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null;
+ page = undefined;
+ cursor = next;
+ if (!cursor) break;
+ }
+ if (cursor) complete = false;
+
+ const turns = sortHistoryTurns([...byId.values()]);
+ await this.hydrateTurnItems(threadId, turns, (value) => {
+ complete = complete && value;
+ });
+ this.historyComplete = complete;
+ return turns;
+ }
+
+ private async hydrateTurnItems(
+ threadId: string,
+ turns: JsonObject[],
+ markComplete: (complete: boolean) => void,
+ ): Promise {
+ for (const turn of turns) {
+ if (turn.itemsView === "full" || typeof turn.id !== "string") continue;
+ const items: JsonObject[] = Array.isArray(turn.items)
+ ? turn.items.filter(isRecord).map((item) => ({ ...(item as JsonObject) }))
+ : [];
+ const itemIds = new Set(items.map((item) => typeof item.id === "string" ? item.id : ""));
+ let cursor: string | null = null;
+ let complete = true;
+ try {
+ for (let pageIndex = 0; pageIndex < MAX_HISTORY_ITEM_PAGES; pageIndex += 1) {
+ const response = await this.rpc.request("thread/items/list", {
+ threadId,
+ turnId: turn.id,
+ limit: HISTORY_PAGE_SIZE,
+ sortDirection: "asc",
+ ...(cursor ? { cursor } : {}),
+ });
+ const page = isRecord(response) ? response : {};
+ for (const entry of Array.isArray(page.data) ? page.data : []) {
+ if (!isRecord(entry) || !isRecord(entry.item)) continue;
+ const item = { ...entry.item };
+ const id = typeof item.id === "string" ? item.id : "";
+ if (!id || !itemIds.has(id)) {
+ items.push(item);
+ if (id) itemIds.add(id);
+ }
+ }
+ const next = typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null;
+ cursor = next;
+ if (!cursor) break;
+ if (pageIndex === MAX_HISTORY_ITEM_PAGES - 1) complete = false;
+ }
+ } catch (error) {
+ complete = false;
+ this.options.logger?.debug?.(`Unable to hydrate items for turn ${turn.id}`, error);
+ }
+ turn.items = items;
+ turn.itemsView = "full";
+ markComplete(complete);
+ }
+ }
+
+ private commitThreadResult(result: JsonValue, replaceHistory: boolean, hydratedThread?: JsonObject): void {
+ const thread = hydratedThread ?? extractThread(result);
+ if (!thread) throw new Error("app-server thread response did not include a thread");
+ const nextThreadId = typeof thread.id === "string" ? thread.id : undefined;
+ if (!nextThreadId) throw new Error("app-server thread response did not include thread.id");
+
+ this.threadId = nextThreadId;
+ if (replaceHistory) {
+ this.messages = projectThreadMessages(thread);
+ this.outputTail = outputTailFromMessages(this.messages, this.options.maxOutputTailChars);
+ }
+ const activeTurn = latestActiveTurn(thread);
+ this.turnId = activeTurn && typeof activeTurn.id === "string" ? activeTurn.id : null;
+ this.state = this.turnId ? "active" : statusToState(thread.status);
+ if (this.state === "notLoaded" || this.state === "unknown") this.state = "idle";
+
+ const response = isRecord(result) ? result : {};
+ const title = sessionTitle(
+ typeof thread.name === "string" ? thread.name : typeof thread.preview === "string" ? thread.preview : "",
+ nextThreadId,
+ );
+ const cwd = typeof response.cwd === "string"
+ ? redactText(response.cwd)
+ : typeof thread.cwd === "string" ? redactText(thread.cwd) : undefined;
+ const model = typeof response.model === "string" ? response.model : undefined;
+ const effort = response.reasoningEffort === null || typeof response.reasoningEffort === "string"
+ ? response.reasoningEffort
+ : undefined;
+ const threadSettings: JsonObject = {
+ ...(cwd ? { cwd } : {}),
+ ...(model ? { model } : {}),
+ ...(effort !== undefined ? { effort: asJsonValue(effort) } : {}),
+ ...(response.modelProvider !== undefined ? { modelProvider: redactJson(response.modelProvider) } : {}),
+ ...(response.serviceTier !== undefined ? { serviceTier: redactJson(response.serviceTier) } : {}),
+ ...(response.approvalPolicy !== undefined ? { approvalPolicy: redactJson(response.approvalPolicy) } : {}),
+ ...(response.approvalsReviewer !== undefined ? { approvalsReviewer: redactJson(response.approvalsReviewer) } : {}),
+ ...(response.sandbox !== undefined ? { sandboxPolicy: redactJson(response.sandbox) } : {}),
+ };
+ this.sessionMetadata = {
+ thread: threadMetadata(thread),
+ title,
+ ...(cwd ? { cwd } : {}),
+ ...(model ? { model, latestModel: model } : {}),
+ ...(effort !== undefined ? { effort: asJsonValue(effort), latestReasoningEffort: asJsonValue(effort) } : {}),
+ ...(response.modelProvider !== undefined ? { modelProvider: redactJson(response.modelProvider) } : {}),
+ ...(response.approvalPolicy !== undefined ? { approvalPolicy: redactJson(response.approvalPolicy) } : {}),
+ ...(response.approvalsReviewer !== undefined ? { approvalsReviewer: redactJson(response.approvalsReviewer) } : {}),
+ ...(response.sandbox !== undefined ? { sandboxPolicy: redactJson(response.sandbox) } : {}),
+ threadSettings,
+ };
+ }
+
+ private mergeThreadSettings(settings: JsonObject): void {
+ const current = isRecord(this.sessionMetadata.threadSettings)
+ ? this.sessionMetadata.threadSettings
+ : {};
+ const next = { ...current, ...redactJson(settings) as JsonObject };
+ this.sessionMetadata.threadSettings = next;
+ for (const key of ["model", "modelProvider", "serviceTier", "approvalPolicy", "approvalsReviewer", "sandboxPolicy", "permissions", "cwd"] as const) {
+ if (settings[key] !== undefined) this.sessionMetadata[key] = redactJson(settings[key]);
+ }
+ if (settings.model !== undefined) this.sessionMetadata.latestModel = redactJson(settings.model);
+ if (settings.effort !== undefined) {
+ this.sessionMetadata.effort = redactJson(settings.effort);
+ this.sessionMetadata.latestReasoningEffort = redactJson(settings.effort);
+ }
+ }
+
+ private async publishHistorySnapshot(): Promise {
+ const snapshot = await this.snapshot();
+ this.emit({
+ type: "output.snapshot",
+ threadId: this.threadId ?? undefined,
+ turnId: this.turnId ?? undefined,
+ payload: {
+ stream: "codex",
+ text: this.outputTail,
+ messages: this.messages.map((message) => asJsonValue(message)),
+ structureChanged: true,
+ historyComplete: this.historyComplete,
+ encoding: "utf8",
+ metadata: snapshot.metadata ?? {},
+ status: snapshot.status ? asJsonValue(snapshot.status) : null,
+ },
+ });
+ await this.publishAuthoritativeSnapshot(snapshot);
+ }
+
+ private async publishAuthoritativeSnapshot(existingSnapshot?: SessionSnapshot): Promise {
+ const snapshot = existingSnapshot ?? await this.snapshot();
+ this.emit({
+ type: "session.snapshot",
+ threadId: snapshot.threadId ?? undefined,
+ turnId: snapshot.turnId ?? undefined,
+ payload: asJsonObject(snapshot),
+ status: snapshot.status,
+ });
+ }
+
+ private statusSnapshot(): NonNullable {
+ const currentMessages = this.turnId
+ ? this.messages.filter((message) => isRecord(message) && message.turnId === this.turnId)
+ : [];
+ const startedAtValues = currentMessages
+ .map((message) => isRecord(message) ? finiteNumber(message.startedAtMs) : undefined)
+ .filter((value): value is number => value !== undefined);
+ const durationValues = currentMessages
+ .map((message) => isRecord(message) ? finiteNumber(message.durationMs) : undefined)
+ .filter((value): value is number => value !== undefined);
+ const startedAtMs = startedAtValues.length ? Math.min(...startedAtValues) : null;
+ const durationMs = durationValues.length ? Math.max(...durationValues) : null;
+ const pendingApprovals = [...this.pending.values()].some((entry) => Boolean(entry.approval));
+ const pendingInput = [...this.pending.values()].some((entry) => !entry.approval);
+ const latest = currentMessages.length && isRecord(currentMessages[currentMessages.length - 1])
+ ? currentMessages[currentMessages.length - 1] as JsonObject
+ : undefined;
+ let activity = this.turnId ? "thinking" : this.state;
+ if (pendingApprovals) activity = "waitingOnApproval";
+ else if (pendingInput) activity = "waitingOnUserInput";
+ else if (latest?.kind === "edit") activity = "editing";
+ else if (latest?.itemType === "commandExecution") activity = "running";
+ else if (latest?.kind === "reasoning" || latest?.kind === "plan") activity = "thinking";
+ return {
+ activity,
+ turnStatus: this.turnId ? "inProgress" : this.state === "idle" ? "completed" : this.state,
+ activeFlags: [
+ ...(pendingApprovals ? ["waitingOnApproval"] : []),
+ ...(pendingInput ? ["waitingOnUserInput"] : []),
+ ],
+ startedAtMs,
+ durationMs,
+ elapsedMs: this.turnId && startedAtMs !== null ? Math.max(0, Date.now() - startedAtMs) : null,
+ };
+ }
+
+ private upsertItem(item: JsonObject, turnId?: string, turn?: JsonObject, lifecycle: JsonObject = {}): void {
+ const projected = projectThreadItem(item, turnId, turn, lifecycle);
+ const itemId = typeof projected.itemId === "string" ? projected.itemId : undefined;
+ const index = itemId
+ ? this.messages.findIndex((message) => isRecord(message)
+ && message.itemId === itemId
+ && (turnId === undefined || message.turnId === turnId))
+ : -1;
+ if (index >= 0) this.messages[index] = projected;
+ else this.messages.push(projected);
+ }
+
+ private appendItemDelta(params: JsonObject, kind: "assistant" | "reasoning" | "plan" | "output", delta: string): void {
+ const itemId = this.extractString(params, "itemId");
+ const turnId = this.extractString(params, "turnId");
+ if (!itemId) return;
+ let index = this.messages.findIndex((message) => isRecord(message)
+ && message.itemId === itemId
+ && (turnId === undefined || message.turnId === turnId));
+ if (index < 0) {
+ const placeholder: JsonObject = {
+ id: itemId,
+ itemId,
+ ...(turnId ? { turnId } : {}),
+ itemType: kind === "output" ? "commandExecution" : kind === "assistant" ? "agentMessage" : kind,
+ role: kind === "assistant" ? "assistant" : kind === "reasoning" ? "reasoning" : "tool",
+ kind: kind === "output" ? "tool" : kind,
+ text: "",
+ status: "inProgress",
+ };
+ this.messages.push(placeholder);
+ index = this.messages.length - 1;
+ }
+ const current = isRecord(this.messages[index]) ? this.messages[index] as JsonObject : {};
+ if (kind === "output") current.output = `${typeof current.output === "string" ? current.output : ""}${redactText(delta)}`;
+ else current.text = `${typeof current.text === "string" ? current.text : ""}${redactText(delta)}`;
+ this.messages[index] = current;
+ }
+
+ private normalizeTurnParams(input: JsonObject, steering = false): JsonObject {
+ const params: JsonObject = { ...input };
+ const threadId = this.stringParam(params, "threadId") ?? this.threadId;
+ if (!threadId) throw new Error(`${steering ? "turn/steer" : "turn/start"} requires threadId (start a thread first)`);
+ params.threadId = threadId;
+
+ const suppliedInput = params.input;
+ if (typeof suppliedInput === "string") {
+ params.input = [this.textInput(suppliedInput)];
+ } else if (Array.isArray(suppliedInput)) {
+ params.input = suppliedInput.map((item) => (typeof item === "string" ? this.textInput(item) : asJsonValue(item)));
+ } else {
+ const text = this.stringParam(params, "text") ?? this.stringParam(params, "message") ?? this.stringParam(params, "prompt");
+ if (!text) throw new Error("turn request requires input or text");
+ params.input = [this.textInput(text)];
+ }
+ delete params.text;
+ delete params.message;
+ delete params.prompt;
+ if (steering) {
+ const expectedTurnId = this.stringParam(params, "expectedTurnId") ?? this.turnId;
+ if (!expectedTurnId) throw new Error("turn/steer requires expectedTurnId (no active turn)");
+ params.expectedTurnId = expectedTurnId;
+ }
+ return params;
+ }
+
+ private textInput(text: string): JsonObject {
+ return { type: "text", text, text_elements: [] };
+ }
+
+ private stringParam(params: JsonObject, key: string): string | undefined {
+ return typeof params[key] === "string" ? (params[key] as string) : undefined;
+ }
+
+ private handleNotification(method: string, rawParams: JsonValue | undefined): void {
+ const params = asJsonObject(rawParams);
+ const threadId = this.extractString(params, "threadId") ?? this.extractNestedString(params, "thread", "id");
+ const turnId = this.extractString(params, "turnId") ?? this.extractNestedString(params, "turn", "id");
+ if (threadId && this.threadId && threadId !== this.threadId) {
+ this.options.logger?.debug?.(`Ignored late ${method} notification for non-selected thread ${threadId}`);
+ return;
+ }
+ const activeTurnId = this.turnId;
+ if (threadId && !this.threadId) this.threadId = threadId;
+ // A late completion for an earlier turn must not overwrite a newer turn
+ // that was started while the old completion notification was in flight.
+ // Token usage is thread telemetry, not a lifecycle transition. It can be
+ // delivered after `turn/completed`, so do not resurrect an old turn (or
+ // replace a newer active turn) just because the notification carries a
+ // turnId.
+ if (turnId
+ && method !== "thread/tokenUsage/updated"
+ && (method !== "turn/completed" || !activeTurnId || activeTurnId === turnId)) {
+ this.turnId = turnId;
+ }
+
+ let type = "app-server.notification";
+ let payload: JsonObject = { method, params: redactJson(params) as JsonObject };
+ let outputText: string | undefined;
+
+ switch (method) {
+ case "thread/started":
+ type = "session.created";
+ payload = { thread: redactJson(params.thread ?? params) as JsonValue };
+ if (isRecord(params.thread)) {
+ try {
+ this.commitThreadResult({ thread: params.thread }, true);
+ } catch (error) {
+ this.options.logger?.debug?.("Unable to hydrate thread/started notification", error);
+ this.state = "idle";
+ }
+ } else {
+ this.state = "idle";
+ }
+ break;
+ case "thread/name/updated": {
+ const title = this.extractString(params, "threadName")?.trim();
+ if (title) this.sessionMetadata.title = redactText(title);
+ payload = redactJson(params) as JsonObject;
+ break;
+ }
+ case "thread/settings/updated":
+ payload = redactJson(params) as JsonObject;
+ if (isRecord(params.threadSettings)) this.mergeThreadSettings(params.threadSettings);
+ break;
+ case "thread/tokenUsage/updated": {
+ const tokenUsage = projectTokenUsage(params.tokenUsage);
+ // `redactJson` treats every key containing "token" as secret. Keep
+ // its redacted params for diagnostics, then add the numeric usage
+ // projection that the browser usage picker and relay snapshot need.
+ payload = redactJson(params) as JsonObject;
+ if (tokenUsage) {
+ this.sessionMetadata.tokenUsage = tokenUsage;
+ // The official extension names this field latestTokenUsageInfo;
+ // retain the shorter alias for existing relay/browser clients.
+ this.sessionMetadata.latestTokenUsageInfo = tokenUsage;
+ payload.tokenUsage = tokenUsage;
+ payload.latestTokenUsageInfo = tokenUsage;
+ // Persist usage through the same authoritative snapshot channel as
+ // thread settings so a browser reconnect does not fall back to the
+ // previous context-window value.
+ void this.publishAuthoritativeSnapshot().catch((error) => {
+ this.options.logger?.debug?.("Unable to publish token usage snapshot", error);
+ });
+ } else {
+ // Do not let the redaction sentinel for malformed usage data look
+ // like a real update and clear a previously valid browser value.
+ delete payload.tokenUsage;
+ delete payload.latestTokenUsageInfo;
+ }
+ break;
+ }
+ case "thread/status/changed":
+ type = "session.state";
+ payload = redactJson(params) as JsonObject;
+ this.state = statusToState(params.status);
+ break;
+ case "thread/closed":
+ case "thread/deleted":
+ type = "session.closed";
+ payload = redactJson(params) as JsonObject;
+ this.state = "closed";
+ this.threadId = null;
+ this.turnId = null;
+ this.messages = [];
+ this.outputTail = "";
+ this.sessionMetadata = {};
+ this.historyComplete = true;
+ break;
+ case "turn/started":
+ type = "task.started";
+ payload = redactJson(params) as JsonObject;
+ this.state = "active";
+ if (isRecord(params.turn) && Array.isArray(params.turn.items)) {
+ const turn = asJsonObject(params.turn);
+ for (const item of params.turn.items.filter(isRecord)) this.upsertItem(asJsonObject(item), turnId, turn);
+ }
+ break;
+ case "turn/completed": {
+ const status = this.extractNestedString(params, "turn", "status");
+ type = status === "interrupted" ? "task.cancelled" : "task.finished";
+ payload = redactJson(params) as JsonObject;
+ // Do not let a stale completion transition a newer active turn to
+ // idle. Notifications are asynchronous and can arrive after the
+ // caller has already started the next turn.
+ if (!turnId || !activeTurnId || turnId === activeTurnId) {
+ this.state = "idle";
+ this.turnId = null;
+ }
+ if (isRecord(params.turn) && Array.isArray(params.turn.items)) {
+ const turn = asJsonObject(params.turn);
+ for (const item of params.turn.items.filter(isRecord)) this.upsertItem(asJsonObject(item), turnId, turn);
+ }
+ break;
+ }
+ case "item/agentMessage/delta":
+ type = "output.chunk";
+ outputText = this.extractString(params, "delta");
+ if (outputText) this.appendItemDelta(params, "assistant", outputText);
+ payload = { stream: "codex", text: redactText(outputText ?? ""), encoding: "utf8" };
+ break;
+ case "item/plan/delta":
+ type = "output.chunk";
+ outputText = this.extractString(params, "delta") ?? this.extractString(params, "text");
+ if (outputText) this.appendItemDelta(params, "plan", outputText);
+ payload = { stream: "reasoning", text: redactText(outputText ?? ""), encoding: "utf8" };
+ break;
+ case "item/reasoning/summaryTextDelta":
+ case "item/reasoning/textDelta":
+ type = "output.chunk";
+ outputText = this.extractString(params, "delta") ?? this.extractString(params, "text");
+ if (outputText) this.appendItemDelta(params, "reasoning", outputText);
+ payload = { stream: "reasoning", text: redactText(outputText ?? ""), encoding: "utf8" };
+ break;
+ case "command/exec/outputDelta":
+ case "process/outputDelta":
+ case "item/commandExecution/outputDelta":
+ type = "output.chunk";
+ outputText = decodeOutput(params);
+ if (method === "item/commandExecution/outputDelta" && outputText) {
+ this.appendItemDelta(params, "output", outputText);
+ }
+ payload = {
+ stream: outputStream(params),
+ text: redactText(outputText),
+ encoding: "utf8",
+ };
+ break;
+ case "item/fileChange/outputDelta":
+ type = "output.chunk";
+ outputText = this.extractString(params, "delta");
+ if (outputText) this.appendItemDelta(params, "output", outputText);
+ payload = { stream: "codex", text: redactText(outputText ?? ""), encoding: "utf8" };
+ break;
+ case "item/started":
+ type = "item.started";
+ payload = redactJson(params) as JsonObject;
+ if (isRecord(params.item)) {
+ this.upsertItem(params.item, turnId, undefined, {
+ ...(finiteNumber(params.startedAtMs) !== undefined ? { startedAtMs: finiteNumber(params.startedAtMs) as number } : {}),
+ status: "inProgress",
+ });
+ }
+ break;
+ case "item/completed":
+ type = "item.completed";
+ payload = redactJson(params) as JsonObject;
+ if (isRecord(params.item)) {
+ this.upsertItem(params.item, turnId, undefined, {
+ ...(finiteNumber(params.completedAtMs) !== undefined ? { completedAtMs: finiteNumber(params.completedAtMs) as number } : {}),
+ });
+ }
+ break;
+ case "serverRequest/resolved":
+ payload = redactJson(params) as JsonObject;
+ if (isJsonRpcId(params.requestId)) {
+ const pending = this.pending.get(jsonRpcIdKey(params.requestId));
+ type = pending?.approval ? "approval.resolved" : pending ? "input.resolved" : "approval.resolved";
+ if (pending?.timer) clearTimeout(pending.timer);
+ this.pending.delete(jsonRpcIdKey(params.requestId));
+ } else {
+ type = "approval.resolved";
+ }
+ break;
+ case "error":
+ type = "error";
+ payload = redactJson(params) as JsonObject;
+ break;
+ case "warning":
+ case "guardianWarning":
+ type = "warning";
+ payload = redactJson(params) as JsonObject;
+ break;
+ default:
+ break;
+ }
+
+ // Events and snapshots must expose the same redacted view. Keeping raw
+ // text in outputTail would leak credentials through `snapshot()` even
+ // though the corresponding output event was redacted.
+ if (outputText) this.appendOutput(outputText);
+ this.emit({
+ type,
+ threadId,
+ turnId,
+ payload,
+ raw: redactJson({ method, params }) as JsonValue,
+ });
+ }
+
+ private handleServerRequest(request: JsonRpcRequest): void {
+ const params = asJsonObject(request.params);
+ const requestThreadId = this.extractString(params, "threadId") ?? this.extractString(params, "conversationId");
+ if (requestThreadId && this.threadId && requestThreadId !== this.threadId) {
+ // A resumed app-server can finish delivering an old request after the
+ // browser has selected another thread. Never expose or retain it as an
+ // approval for the selected conversation.
+ try {
+ if (APPROVAL_METHODS.has(request.method) || INPUT_REQUEST_METHODS.has(request.method)) {
+ this.rpc.respond(request.id, normalizeServerResponse(
+ request.method,
+ this.defaultApprovalResponse(request.method, request.params, "deny", "thread is no longer selected"),
+ ));
+ } else {
+ this.rpc.respondError(request.id, -32000, "thread is no longer selected");
+ }
+ } catch {
+ // The child may have exited while the stale request was in flight.
+ }
+ this.options.logger?.debug?.(`Rejected stale ${request.method} request for non-selected thread ${requestThreadId}`);
+ return;
+ }
+ if (APPROVAL_METHODS.has(request.method)) {
+ const approval = this.toPendingApproval(request, params);
+ const entry: PendingRequest = { request, approval };
+ if (this.options.approvalTimeoutMs > 0) {
+ entry.timer = setTimeout(() => this.expireApproval(request.id), this.options.approvalTimeoutMs);
+ approval.expiresAt = Date.now() + this.options.approvalTimeoutMs;
+ }
+ this.pending.set(jsonRpcIdKey(request.id), entry);
+ this.emit({
+ type: "approval.requested",
+ threadId: approval.threadId,
+ turnId: approval.turnId,
+ requestId: request.id,
+ payload: {
+ ...approval.payload,
+ params: approval.payload,
+ requestId: asJsonValue(request.id),
+ method: request.method,
+ action: approval.action,
+ risk: approval.risk,
+ summary: approval.summary,
+ ...(approval.commandHash ? { commandHash: approval.commandHash } : {}),
+ ...(approval.expiresAt ? { expiresAt: approval.expiresAt } : {}),
+ },
+ raw: redactJson(request) as JsonValue,
+ });
+ return;
+ }
+
+ if (INPUT_REQUEST_METHODS.has(request.method)) {
+ const entry: PendingRequest = { request };
+ if (this.options.approvalTimeoutMs > 0) {
+ entry.timer = setTimeout(() => this.expirePendingRequest(request.id), this.options.approvalTimeoutMs);
+ }
+ this.pending.set(jsonRpcIdKey(request.id), entry);
+ this.emit({
+ type: "input.requested",
+ threadId: this.extractString(params, "threadId"),
+ turnId: this.extractString(params, "turnId"),
+ requestId: request.id,
+ payload: { requestId: asJsonValue(request.id), method: request.method, params: redactJson(params) as JsonValue },
+ raw: redactJson(request) as JsonValue,
+ });
+ return;
+ }
+
+ this.emit({ type: "server.request", requestId: request.id, payload: { method: request.method, params: redactJson(params) as JsonValue }, raw: redactJson(request) as JsonValue });
+ void this.resolveServerRequest(request);
+ }
+
+ private async resolveServerRequest(request: JsonRpcRequest): Promise {
+ try {
+ const result = await this.options.onServerRequest?.(request);
+ if (result !== undefined) {
+ this.rpc.respond(request.id, result);
+ } else if (this.options.autoRejectUnsupportedRequests !== false) {
+ this.rpc.respondError(request.id, -32601, `Unsupported app-server request: ${request.method}`);
+ }
+ } catch (error) {
+ this.rpc.respondError(request.id, -32000, error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ private toPendingApproval(request: JsonRpcRequest, params: JsonObject): PendingApproval {
+ const threadId = this.extractString(params, "threadId") ?? this.extractString(params, "conversationId");
+ const turnId = this.extractString(params, "turnId");
+ const itemId = this.extractString(params, "itemId") ?? this.extractString(params, "callId");
+ const command = this.extractString(params, "command") ?? this.extractCommand(params);
+ const reason = this.extractString(params, "reason");
+ const action = approvalAction(request.method);
+ const risk = approvalRisk(request.method, command, params.commandActions);
+ const summary = reason || command || `${action} requested by Codex`;
+ return {
+ requestId: request.id,
+ method: request.method,
+ threadId,
+ turnId,
+ itemId,
+ action,
+ risk,
+ summary: redactText(summary),
+ commandHash: hashJson(params),
+ createdAt: Date.now(),
+ payload: redactJson(params) as JsonObject,
+ };
+ }
+
+ private async expireApproval(requestId: JsonRpcId): Promise {
+ return this.expirePendingRequest(requestId);
+ }
+
+ private async expirePendingRequest(requestId: JsonRpcId): Promise {
+ const key = jsonRpcIdKey(requestId);
+ const pending = this.pending.get(key);
+ if (!pending) return;
+ this.pending.delete(key);
+ try {
+ this.rpc.respond(requestId, normalizeServerResponse(
+ pending.request.method,
+ this.expiredApprovalResponse(pending.request.method, pending.request.params),
+ ));
+ } catch {
+ // The app-server may have exited while the timer was pending.
+ }
+ this.emit({
+ type: pending.approval ? "approval.expired" : "input.expired",
+ threadId: pending.approval?.threadId,
+ turnId: pending.approval?.turnId,
+ requestId,
+ payload: { requestId: asJsonValue(requestId), reason: "approval expired" },
+ });
+ }
+
+ private dropPendingRequests(reason: string): void {
+ const pendingEntries = [...this.pending.values()];
+ this.pending.clear();
+ for (const pending of pendingEntries) {
+ if (pending.timer) clearTimeout(pending.timer);
+ this.emit({
+ type: pending.approval ? "approval.expired" : "input.expired",
+ threadId: pending.approval?.threadId,
+ turnId: pending.approval?.turnId,
+ requestId: pending.request.id,
+ payload: { requestId: asJsonValue(pending.request.id), reason },
+ });
+ }
+ }
+
+ private defaultApprovalResponse(method: string, rawParams: JsonValue | undefined, decision: "allow" | "deny" | "cancel", reason?: string): JsonValue {
+ const params = asJsonObject(rawParams);
+ if (method === "item/permissions/requestApproval") {
+ return {
+ permissions: decision === "allow" ? (params.permissions ?? {}) : {},
+ scope: "turn",
+ };
+ }
+ if (method === "item/tool/requestUserInput") {
+ return { answers: {} };
+ }
+ if (method === "mcpServer/elicitation/request") {
+ return { action: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline", content: null, _meta: null };
+ }
+ if (method === "applyPatchApproval" || method === "execCommandApproval") {
+ if (decision === "allow") return { decision: "approved" };
+ if (decision === "cancel") return { decision: "abort" };
+ return { decision: { denied: { rejection: reason || "Denied remotely" } } };
+ }
+ return { decision: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline" };
+ }
+
+ private expiredApprovalResponse(method: string, rawParams: JsonValue | undefined): JsonValue {
+ if (method === "applyPatchApproval" || method === "execCommandApproval") {
+ // Preserve the legacy app-server wire decision for an actual timeout;
+ // `denied` is reserved for an explicit policy rejection.
+ return { decision: "timed_out" };
+ }
+ return this.defaultApprovalResponse(method, rawParams, "deny", "approval expired");
+ }
+
+ private extractString(params: JsonObject, key: string): string | undefined {
+ return typeof params[key] === "string" ? (params[key] as string) : undefined;
+ }
+
+ private extractNestedString(params: JsonObject, parent: string, key: string): string | undefined {
+ const nested = params[parent];
+ return isRecord(nested) && typeof nested[key] === "string" ? (nested[key] as string) : undefined;
+ }
+
+ private extractCommand(params: JsonObject): string | undefined {
+ const command = params.command;
+ if (Array.isArray(command)) return command.filter((item): item is string => typeof item === "string").join(" ");
+ // Newer command-approval requests may leave `command` null while
+ // providing parsed actions. Include every action command in the risk
+ // input so a dangerous subcommand cannot be hidden behind command:null.
+ if (Array.isArray(params.commandActions)) {
+ const commands = params.commandActions
+ .map((action) => isRecord(action) && typeof action.command === "string" ? action.command : undefined)
+ .filter((item): item is string => Boolean(item));
+ if (commands.length) return commands.join(" && ");
+ }
+ return undefined;
+ }
+
+ private appendOutput(text: string): void {
+ // Keep this invariant at the storage boundary. New notification handlers
+ // can append raw text later without creating a snapshot-only secret leak.
+ const safeText = redactText(text);
+ this.outputTail = `${this.outputTail}${safeText}`;
+ if (this.outputTail.length > this.options.maxOutputTailChars) {
+ this.outputTail = this.outputTail.slice(-this.options.maxOutputTailChars);
+ }
+ }
+
+ private emit(event: AgentEvent): void {
+ for (const listener of this.listeners) {
+ try {
+ listener(event);
+ } catch (error) {
+ this.options.logger?.warn?.("Agent event listener failed", error);
+ }
+ }
+ }
+}
+
+const THREAD_SORT_KEYS = new Set(["created_at", "updated_at", "recency_at", "section_position"]);
+const THREAD_SOURCE_KINDS = new Set([
+ "cli", "vscode", "exec", "appServer", "subAgent", "subAgentReview",
+ "subAgentCompact", "subAgentThreadSpawn", "subAgentOther", "unknown",
+]);
+
+function normalizeThreadListParams(params: JsonObject): JsonObject {
+ const result: JsonObject = {};
+ if (params.cursor === null || typeof params.cursor === "string") result.cursor = params.cursor;
+ const limit = finiteNumber(params.limit);
+ result.limit = Math.max(1, Math.min(100, Number.isInteger(limit) ? limit as number : 50));
+ const sortKey = typeof params.sortKey === "string" ? params.sortKey : "updated_at";
+ result.sortKey = THREAD_SORT_KEYS.has(sortKey) ? sortKey : "updated_at";
+ result.sortDirection = params.sortDirection === "asc" ? "asc" : "desc";
+ if (typeof params.archived === "boolean") result.archived = params.archived;
+ if (params.sectionId === null || typeof params.sectionId === "string") result.sectionId = params.sectionId;
+ if (typeof params.useStateDbOnly === "boolean") result.useStateDbOnly = params.useStateDbOnly;
+ const searchTerm = typeof params.searchTerm === "string"
+ ? params.searchTerm
+ : typeof params.query === "string" ? params.query : undefined;
+ if (searchTerm?.trim()) result.searchTerm = searchTerm.trim();
+ if (typeof params.cwd === "string") result.cwd = params.cwd;
+ else if (Array.isArray(params.cwd) && params.cwd.every((value) => typeof value === "string")) {
+ result.cwd = asJsonValue(params.cwd);
+ }
+ if (Array.isArray(params.modelProviders) && params.modelProviders.every((value) => typeof value === "string")) {
+ result.modelProviders = asJsonValue(params.modelProviders);
+ }
+ if (Array.isArray(params.sourceKinds)) {
+ const sourceKinds = params.sourceKinds.filter((value): value is string => typeof value === "string" && THREAD_SOURCE_KINDS.has(value));
+ if (sourceKinds.length) result.sourceKinds = sourceKinds;
+ }
+ return result;
+}
+
+function normalizeThreadSettings(params: JsonObject): { wire: JsonObject; display: JsonObject } {
+ const source = isRecord(params.threadSettings) ? params.threadSettings : params;
+ const wire: JsonObject = {};
+ const display: JsonObject = {};
+ for (const key of ["model", "cwd", "effort", "serviceTier", "summary", "personality"] as const) {
+ if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
+ const value = source[key];
+ if (value !== null && (typeof value !== "string" || !value.trim())) {
+ throw new Error(`thread settings ${key} must be a non-empty string or null`);
+ }
+ wire[key] = typeof value === "string" ? value.trim() : null;
+ display[key] = wire[key];
+ }
+ for (const key of ["collaborationMode", "multiAgentMode"] as const) {
+ if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
+ const value = source[key];
+ if (value !== null && typeof value !== "string" && !isRecord(value)) {
+ throw new Error(`thread settings ${key} must be a string, object, or null`);
+ }
+ wire[key] = asJsonValue(value);
+ display[key] = wire[key];
+ }
+ for (const key of ["approvalPolicy", "approvalsReviewer"] as const) {
+ if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
+ const value = source[key];
+ if (value !== null && typeof value !== "string") {
+ throw new Error(`thread settings ${key} must be a string or null`);
+ }
+ wire[key] = asJsonValue(value);
+ display[key] = wire[key];
+ }
+
+ const hasPermissions = Object.prototype.hasOwnProperty.call(source, "permissions");
+ const hasSandboxPolicy = Object.prototype.hasOwnProperty.call(source, "sandboxPolicy");
+ if (hasPermissions) {
+ const value = source.permissions;
+ if (value !== null && (typeof value !== "string" || !value.trim())) {
+ throw new Error("thread settings permissions must be a non-empty string or null");
+ }
+ wire.permissions = typeof value === "string" ? value.trim() : null;
+ display.permissions = wire.permissions;
+ }
+ if (hasSandboxPolicy) {
+ const value = source.sandboxPolicy;
+ if (value !== null && typeof value !== "string" && !isRecord(value)) {
+ throw new Error("thread settings sandboxPolicy must be a string, object, or null");
+ }
+ display.sandboxPolicy = asJsonValue(value);
+ if (!hasPermissions) {
+ if (typeof value === "string") {
+ const permission = LEGACY_SANDBOX_PERMISSIONS[value];
+ if (!permission) throw new Error(`unsupported legacy sandbox policy: ${value}`);
+ wire.permissions = permission;
+ display.permissions = permission;
+ } else {
+ wire.sandboxPolicy = asJsonValue(value);
+ }
+ }
+ }
+ if (!Object.keys(wire).length) throw new Error("thread settings update requires at least one setting");
+ return { wire, display };
+}
+
+const LEGACY_SANDBOX_PERMISSIONS: Record = {
+ "read-only": ":read-only",
+ "workspace-write": ":workspace",
+ "danger-full-access": ":danger-full-access",
+};
+
+function isActiveWriterError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error ?? "");
+ return /active\s+writer|writer\s+lock|already\s+has\s+an\s+active\s+writer|thread\s+.*(?:locked|lock)|lock\s+.*thread/i.test(message);
+}
+
+function isPaginationUnsupportedError(error: unknown): boolean {
+ if (isActiveWriterError(error)) return false;
+ const code = isRecord(error) && typeof error.code === "number" ? error.code : undefined;
+ if (code === -32602 || code === -32601) return true;
+ const message = error instanceof Error ? error.message : String(error ?? "");
+ return /unknown\s+(?:field|parameter|method)|method\s+.*not\s+found|invalid\s+(?:param(?:eter)?s?|field)|unexpected\s+(?:field|property)|unsupported\s+(?:pagination|initialTurnsPage|excludeTurns|itemsView)/i.test(message);
+}
+
+function sortHistoryTurns(turns: JsonObject[]): JsonObject[] {
+ return turns
+ .map((turn, index) => ({ turn, index }))
+ .sort((left, right) => {
+ const leftTime = [left.turn.startedAt, left.turn.createdAt, left.turn.completedAt]
+ .map(finiteNumber)
+ .find((value): value is number => value !== undefined);
+ const rightTime = [right.turn.startedAt, right.turn.createdAt, right.turn.completedAt]
+ .map(finiteNumber)
+ .find((value): value is number => value !== undefined);
+ if (leftTime !== undefined && rightTime !== undefined && leftTime !== rightTime) return leftTime - rightTime;
+ if (leftTime !== undefined && rightTime === undefined) return -1;
+ if (leftTime === undefined && rightTime !== undefined) return 1;
+ const leftId = typeof left.turn.id === "string" ? left.turn.id : "";
+ const rightId = typeof right.turn.id === "string" ? right.turn.id : "";
+ return leftId.localeCompare(rightId) || left.index - right.index;
+ })
+ .map(({ turn }) => turn);
+}
+
+function extractThread(result: JsonValue): JsonObject | undefined {
+ if (!isRecord(result)) return undefined;
+ if (isRecord(result.thread)) return result.thread;
+ return typeof result.id === "string" ? result : undefined;
+}
+
+function threadMetadata(thread: JsonObject): JsonObject {
+ const result = redactJson(thread);
+ if (!isRecord(result)) return {};
+ return { ...result, turns: [] };
+}
+
+const TOKEN_USAGE_FIELDS = [
+ "totalTokens",
+ "inputTokens",
+ "cachedInputTokens",
+ "cacheWriteInputTokens",
+ "outputTokens",
+ "reasoningOutputTokens",
+] as const;
+
+/** Keep only the numeric portion of the official token-usage projection. */
+function projectTokenUsage(value: unknown): JsonObject | undefined {
+ if (!isRecord(value)) return undefined;
+ const source = isRecord(value.info)
+ ? value.info
+ : isRecord(value.tokenUsage)
+ ? value.tokenUsage
+ : isRecord(value.token_usage)
+ ? value.token_usage
+ : value;
+ const total = projectTokenUsageBreakdown(
+ source.total
+ ?? source.total_token_usage
+ ?? source.totalTokenUsage,
+ );
+ const last = projectTokenUsageBreakdown(
+ source.last
+ ?? source.last_token_usage
+ ?? source.lastTokenUsage,
+ );
+ const modelContextWindow = tokenNumber(
+ source.modelContextWindow
+ ?? source.model_context_window
+ ?? source.contextWindow
+ ?? source.context_window,
+ );
+ if (!total && !last && modelContextWindow === undefined) return undefined;
+ return {
+ ...(total ? { total } : {}),
+ ...(last ? { last } : {}),
+ ...(modelContextWindow !== undefined ? { modelContextWindow } : {}),
+ };
+}
+
+function projectTokenUsageBreakdown(value: unknown): JsonObject | undefined {
+ if (!isRecord(value)) return undefined;
+ const aliases: Record<(typeof TOKEN_USAGE_FIELDS)[number], string[]> = {
+ totalTokens: ["totalTokens", "total_tokens"],
+ inputTokens: ["inputTokens", "input_tokens"],
+ cachedInputTokens: ["cachedInputTokens", "cached_input_tokens"],
+ cacheWriteInputTokens: ["cacheWriteInputTokens", "cache_write_input_tokens"],
+ outputTokens: ["outputTokens", "output_tokens"],
+ reasoningOutputTokens: ["reasoningOutputTokens", "reasoning_output_tokens"],
+ };
+ const result: JsonObject = {};
+ for (const field of TOKEN_USAGE_FIELDS) {
+ for (const alias of aliases[field]) {
+ const number = tokenNumber(value[alias]);
+ if (number === undefined) continue;
+ result[field] = number;
+ break;
+ }
+ }
+ return Object.keys(result).length ? result : undefined;
+}
+
+function tokenNumber(value: unknown): number | undefined {
+ if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined;
+ if (typeof value !== "string" || !/^\d+(?:\.\d+)?$/.test(value.trim())) return undefined;
+ const number = Number(value);
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
+}
+
+function latestActiveTurn(thread: JsonObject): JsonObject | undefined {
+ if (!Array.isArray(thread.turns)) return undefined;
+ for (let index = thread.turns.length - 1; index >= 0; index -= 1) {
+ const turn = thread.turns[index];
+ if (isRecord(turn) && turn.status === "inProgress") return turn;
+ }
+ return undefined;
+}
+
+function projectThreadMessages(thread: JsonObject): JsonValue[] {
+ if (!Array.isArray(thread.turns)) return [];
+ const messages: JsonValue[] = [];
+ for (const turn of thread.turns) {
+ if (!isRecord(turn) || !Array.isArray(turn.items)) continue;
+ const turnId = typeof turn.id === "string" ? turn.id : undefined;
+ for (const item of turn.items) {
+ if (isRecord(item)) messages.push(projectThreadItem(item, turnId, turn));
+ }
+ }
+ return messages;
+}
+
+function projectThreadItem(
+ item: JsonObject,
+ turnId?: string,
+ turn?: JsonObject,
+ lifecycle: JsonObject = {},
+): JsonObject {
+ const safeItem = redactJson(item);
+ const projected: JsonObject = isRecord(safeItem) ? { ...safeItem } : {};
+ const itemType = typeof item.type === "string" ? item.type : "unknown";
+ const itemId = typeof item.id === "string" ? item.id : undefined;
+ const turnStatus = typeof turn?.status === "string" ? turn.status : undefined;
+ const startedAtMs = finiteNumber(lifecycle.startedAtMs) ?? secondsToMs(turn?.startedAt);
+ const completedAtMs = finiteNumber(lifecycle.completedAtMs) ?? secondsToMs(turn?.completedAt);
+ const durationMs = finiteNumber(item.durationMs) ?? finiteNumber(turn?.durationMs);
+ const status = typeof lifecycle.status === "string"
+ ? lifecycle.status
+ : typeof item.status === "string" ? item.status : undefined;
+
+ Object.assign(projected, {
+ ...(itemId ? { id: itemId, itemId } : {}),
+ ...(turnId ? { turnId } : {}),
+ itemType,
+ ...(status ? { status } : {}),
+ ...(turnStatus ? { turnStatus } : {}),
+ ...(startedAtMs !== undefined ? { startedAtMs } : {}),
+ ...(completedAtMs !== undefined ? { completedAtMs } : {}),
+ ...(durationMs !== undefined ? { durationMs } : {}),
+ });
+
+ switch (itemType) {
+ case "userMessage":
+ projected.role = "user";
+ projected.kind = "user";
+ projected.text = userInputText(item.content);
+ break;
+ case "agentMessage":
+ projected.role = "assistant";
+ projected.kind = "assistant";
+ projected.text = typeof item.text === "string" ? redactText(item.text) : "";
+ break;
+ case "reasoning":
+ projected.role = "reasoning";
+ projected.kind = "reasoning";
+ projected.text = stringArrayText(item.summary) || stringArrayText(item.content);
+ break;
+ case "plan":
+ projected.role = "reasoning";
+ projected.kind = "plan";
+ projected.text = typeof item.text === "string" ? redactText(item.text) : "";
+ break;
+ case "commandExecution":
+ projected.role = "tool";
+ projected.kind = "tool";
+ projected.command = typeof item.command === "string" ? redactText(item.command) : "";
+ projected.text = typeof item.command === "string" ? redactText(item.command) : "";
+ projected.output = typeof item.aggregatedOutput === "string" ? redactText(item.aggregatedOutput) : "";
+ projected.label = "Command";
+ projected.uiType = "commandExecution";
+ break;
+ case "fileChange": {
+ projected.role = "tool";
+ projected.kind = "edit";
+ const paths = Array.isArray(item.changes)
+ ? item.changes.filter(isRecord).map((change) => {
+ const value = asJsonObject(change);
+ return typeof value.path === "string" ? redactText(value.path) : "";
+ }).filter(Boolean)
+ : [];
+ projected.text = paths.join("\n");
+ projected.label = "File changes";
+ projected.uiType = "fileChange";
+ break;
+ }
+ case "collabAgentToolCall":
+ projected.role = "tool";
+ projected.kind = "tool";
+ projected.text = typeof item.prompt === "string" ? redactText(item.prompt) : typeof item.tool === "string" ? item.tool : "";
+ projected.action = typeof item.tool === "string" ? item.tool : "";
+ projected.uiType = "collabAgentToolCall";
+ break;
+ case "subAgentActivity":
+ projected.role = "tool";
+ projected.kind = "tool";
+ projected.text = typeof item.agentPath === "string" ? redactText(item.agentPath) : "";
+ projected.activityKind = typeof item.kind === "string" ? item.kind : "";
+ projected.uiType = "subAgentActivity";
+ break;
+ case "webSearch":
+ projected.role = "tool";
+ projected.kind = "tool";
+ projected.text = typeof item.query === "string" ? redactText(item.query) : "";
+ projected.label = "Web search";
+ projected.uiType = "webSearch";
+ break;
+ case "imageView":
+ projected.role = "tool";
+ projected.kind = "tool";
+ projected.text = typeof item.path === "string" ? redactText(item.path) : "";
+ projected.label = "Image";
+ break;
+ case "contextCompaction":
+ projected.role = "tool";
+ projected.kind = "tool";
+ projected.text = "Context compacted";
+ projected.uiType = "contextCompaction";
+ break;
+ default:
+ projected.role = "tool";
+ projected.kind = "tool";
+ projected.text = genericItemText(item);
+ break;
+ }
+ return projected;
+}
+
+function userInputText(value: unknown): string {
+ if (!Array.isArray(value)) return "";
+ return value.map((input) => {
+ if (!isRecord(input)) return "";
+ if (input.type === "text" && typeof input.text === "string") return redactText(input.text);
+ if (input.type === "skill" && typeof input.name === "string") return `$${redactText(input.name)}`;
+ if (input.type === "mention" && typeof input.name === "string") return `@${redactText(input.name)}`;
+ if ((input.type === "image" || input.type === "audio") && typeof input.url === "string") return redactText(input.url);
+ if ((input.type === "localImage" || input.type === "localAudio") && typeof input.path === "string") return redactText(input.path);
+ return "";
+ }).filter(Boolean).join("\n");
+}
+
+function stringArrayText(value: unknown): string {
+ return Array.isArray(value)
+ ? value.filter((entry): entry is string => typeof entry === "string").map(redactText).join("\n")
+ : "";
+}
+
+function genericItemText(item: JsonObject): string {
+ for (const key of ["text", "query", "command", "name", "tool"] as const) {
+ if (typeof item[key] === "string") return redactText(item[key] as string);
+ }
+ if (item.output !== undefined) return redactText(stableStringify(redactJson(item.output)));
+ if (item.result !== undefined) return redactText(stableStringify(redactJson(item.result)));
+ return "";
+}
+
+function outputTailFromMessages(messages: JsonValue[], maxChars: number): string {
+ const chunks: string[] = [];
+ for (const message of messages) {
+ if (!isRecord(message)) continue;
+ if (typeof message.text === "string" && message.text) chunks.push(message.text);
+ if (typeof message.output === "string" && message.output) chunks.push(message.output);
+ }
+ const output = redactText(chunks.join("\n\n"));
+ return output.length > maxChars ? output.slice(-maxChars) : output;
+}
+
+function sessionTitle(value: string, threadId: string): string {
+ const title = redactText(value).replace(/\s+/g, " ").trim();
+ if (title) return title.slice(0, 160);
+ return `Session ${threadId.slice(0, 8)}`;
+}
+
+function secondsToMs(value: unknown): number | undefined {
+ const seconds = finiteNumber(value);
+ return seconds === undefined ? undefined : Math.round(seconds * 1_000);
+}
+
+function finiteNumber(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
+}
+
+/** Keep browser/embedding responses aligned with app-server response schemas. */
+function normalizeServerResponse(method: string, response: JsonValue): JsonValue {
+ if (method === "item/permissions/requestApproval") {
+ const source = isRecord(response) ? response : {};
+ const requested = isRecord(source.permissions) ? source.permissions : {};
+ const permissions: JsonObject = {};
+ for (const [key, value] of Object.entries(requested)) {
+ // Request profiles use null to mean "not requested"; granted profiles
+ // omit those fields instead of sending an invalid explicit null.
+ if (value !== null && value !== undefined) permissions[key] = asJsonValue(value);
+ }
+ const normalized: JsonObject = {
+ permissions,
+ scope: source.scope === "session" ? "session" : "turn",
+ };
+ if (typeof source.strictAutoReview === "boolean") normalized.strictAutoReview = source.strictAutoReview;
+ return normalized;
+ }
+ // Tool user-input responses are wrapped in an `answers` object. MCP
+ // elicitation has a different schema (`action`, `content`, `_meta`) and
+ // must be forwarded unchanged; wrapping it would make app-server reject
+ // an otherwise valid approval response.
+ if (method === "mcpServer/elicitation/request") return response;
+ if (method === "item/tool/requestUserInput") {
+ if (isRecord(response) && Object.prototype.hasOwnProperty.call(response, "answers")) return response;
+ return { answers: isRecord(response) ? response : {} };
+ }
+ return response;
+}
+
+/** Validate a response immediately before it crosses the app-server boundary. */
+function validateAdapterResponse(
+ method: string,
+ response: JsonValue,
+ decision: "allow" | "deny" | "cancel",
+): void {
+ if (!isRecord(response)) throw new Error("app-server response must be a JSON object");
+
+ if (method === "item/permissions/requestApproval") {
+ if (!isRecord(response.permissions)
+ || (response.scope !== "turn" && response.scope !== "session")
+ || (response.strictAutoReview !== undefined && typeof response.strictAutoReview !== "boolean")) {
+ throw new Error("invalid permissions approval response");
+ }
+ return;
+ }
+
+ if (method === "item/tool/requestUserInput") {
+ const answers = response.answers;
+ if (!isRecord(answers)) throw new Error("invalid tool input response");
+ return;
+ }
+
+ if (method === "mcpServer/elicitation/request") {
+ if (!Object.prototype.hasOwnProperty.call(response, "action")) {
+ throw new Error("MCP elicitation response requires action");
+ }
+ const action = approvalDecisionKindForMethod(response.action, method);
+ if (!action || action !== decision) throw new Error("MCP elicitation action conflicts with decision");
+ return;
+ }
+
+ // Approval callbacks all use a `decision` field. Unknown or mixed tagged
+ // objects are rejected by the method-aware classifier before write.
+ if (!hasApprovalDecisionField(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) {
+ throw new Error("approval response requires decision");
+ }
+ const responseDecision = approvalDecisionKindForMethod(response.decision, method);
+ if (!responseDecision) throw new Error("unsupported approval response decision");
+ if (responseDecision !== decision) throw new Error(`approval response implies ${responseDecision}, but decision is ${decision}`);
+}
+
+function statusToState(status: unknown): string {
+ if (typeof status === "string") return status;
+ if (isRecord(status) && typeof status.type === "string") return status.type;
+ return "unknown";
+}
+
+function approvalAction(method: string): string {
+ switch (method) {
+ case "item/commandExecution/requestApproval":
+ case "execCommandApproval":
+ return "command.execution";
+ case "item/fileChange/requestApproval":
+ case "applyPatchApproval":
+ return "file.change";
+ case "item/permissions/requestApproval":
+ return "permissions.grant";
+ default:
+ return "approval";
+ }
+}
+
+function approvalRisk(method: string, command?: string, commandActions?: JsonValue): PendingApproval["risk"] {
+ // A permission profile can expand filesystem or network access for the
+ // current turn/session, so treat it like an explicit high-impact command.
+ if (method.includes("permissions")) return "high";
+ if (method.includes("command") || method === "execCommandApproval") {
+ const suspicious = /(?:rm\s+-rf|sudo|curl|wget|ssh|password|token|secret)/i;
+ if (Array.isArray(commandActions)) {
+ // `commandActions` is parsed display data, not a proof of safety. Keep
+ // every request carrying it high-risk, scan all extracted commands, and
+ // treat unknown/malformed actions as high-risk as well. This prevents a
+ // dangerous subcommand from being hidden behind command:null.
+ const actions = commandActions;
+ const actionCommands = actions
+ .map((action) => isRecord(action) && typeof action.command === "string" ? action.command : undefined)
+ .filter((item): item is string => Boolean(item));
+ const malformed = actions.some((action) => {
+ if (!isRecord(action) || typeof action.command !== "string") return true;
+ return action.type !== "read"
+ && action.type !== "listFiles"
+ && action.type !== "search"
+ && action.type !== "unknown";
+ });
+ const combined = [command, ...actionCommands].filter((item): item is string => Boolean(item)).join(" && ");
+ if (malformed || !actionCommands.length || suspicious.test(combined) || actions.some((action) => isRecord(action) && action.type === "unknown")) {
+ return "high";
+ }
+ return "high";
+ }
+ if (command && suspicious.test(command)) return "high";
+ // An unparseable command approval is fail-closed. A missing command can
+ // otherwise be misclassified as medium and approved by the default host
+ // capability policy.
+ if (!command) return "high";
+ return "medium";
+ }
+ if (method.includes("fileChange") || method === "applyPatchApproval") return "medium";
+ return "unknown";
+}
+
+function outputStream(params: JsonObject): string {
+ const stream = params.stream;
+ if (stream === "stderr" || stream === "stdout" || stream === "codex") return stream;
+ return "stdout";
+}
+
+function decodeOutput(params: JsonObject): string {
+ if (typeof params.delta === "string") return params.delta;
+ if (typeof params.deltaBase64 === "string") {
+ try {
+ return Buffer.from(params.deltaBase64, "base64").toString("utf8");
+ } catch {
+ return "[invalid base64 output]";
+ }
+ }
+ return "";
+}
+
+const SECRET_KEY = /(?:token|secret|password|authorization|api[_-]?key|private[_-]?key|refresh)/i;
+const SECRET_VALUE = /(?:Bearer\s+)[A-Za-z0-9._~+\-/]+=*|(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})/g;
+
+function redactText(text: string): string {
+ return text
+ .replace(SECRET_VALUE, "[REDACTED]")
+ .replace(/([?&](?:token|key|secret|password|api[_-]?key)=)[^&\s]+/gi, "$1[REDACTED]")
+ .replace(/((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]");
+}
+
+function redactJson(value: unknown): JsonValue {
+ if (Array.isArray(value)) return value.map((item) => redactJson(item));
+ if (isRecord(value)) {
+ const result: JsonObject = {};
+ for (const [key, child] of Object.entries(value)) {
+ result[key] = SECRET_KEY.test(key) ? "[REDACTED]" : redactJson(child);
+ }
+ return result;
+ }
+ if (typeof value === "string") return redactText(value);
+ return asJsonValue(value);
+}
+
+function hashJson(value: JsonValue): string {
+ return createHash("sha256").update(stableStringify(value)).digest("hex");
+}
+
+function stableStringify(value: JsonValue): string {
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
+ if (value !== null && typeof value === "object") {
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key] ?? null)}`).join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
diff --git a/aether-vscodex/vscode-extension/src/codexIpc.ts b/aether-vscodex/vscode-extension/src/codexIpc.ts
new file mode 100644
index 000000000..32842d045
--- /dev/null
+++ b/aether-vscodex/vscode-extension/src/codexIpc.ts
@@ -0,0 +1,947 @@
+/**
+ * Minimal client for the private Codex desktop/VS Code coordination socket.
+ *
+ * This is intentionally separate from the app-server (JSONL/stdio) adapter.
+ * It attaches to the already running Codex UI through the local IPC router and
+ * therefore does not spawn another `codex` process. The wire protocol is
+ * private and versioned by the official extension; keep this module isolated
+ * so a protocol change can fail without taking down the relay bridge.
+ */
+
+import * as crypto from "node:crypto";
+import * as net from "node:net";
+import * as os from "node:os";
+import * as path from "node:path";
+
+import type { JsonObject, JsonValue } from "./protocol";
+
+export const INITIALIZING_CLIENT_ID = "initializing-client";
+export const DEFAULT_IPC_REQUEST_TIMEOUT_MS = 5_000;
+export const DEFAULT_MAX_IPC_FRAME_BYTES = 256 * 1024 * 1024;
+
+/** Versions shipped by openai.chatgpt 26.820.71523. */
+export const CODEX_IPC_METHOD_VERSIONS = Object.freeze({
+ "thread-stream-state-changed": 11,
+ "thread-stream-following-changed": 1,
+ "thread-stream-following-status-requested": 1,
+ "ipc-connection-reset": 1,
+ "thread-read-state-changed": 2,
+ "thread-archived": 2,
+ "thread-unarchived": 1,
+ "thread-owner-discovery": 1,
+ "thread-follower-start-turn": 2,
+ "thread-follower-load-complete-history": 1,
+ "thread-follower-compact-thread": 1,
+ "thread-follower-steer-turn": 1,
+ "thread-follower-interrupt-turn": 4,
+ "thread-follower-update-thread-settings": 1,
+ "thread-follower-edit-last-user-turn": 2,
+ "thread-follower-command-approval-decision": 1,
+ "thread-follower-file-approval-decision": 1,
+ "thread-follower-permissions-request-approval-response": 1,
+ "thread-follower-submit-user-input": 1,
+ "thread-follower-submit-mcp-server-elicitation-response": 1,
+ "thread-follower-set-queued-follow-ups-state": 1,
+ "thread-queued-followups-changed": 1,
+} as const);
+
+export type IpcMethod = keyof typeof CODEX_IPC_METHOD_VERSIONS;
+export type IpcRequestId = string | number;
+export type IpcPatchPathPart = string | number;
+
+export interface IpcRequest {
+ type: "request";
+ requestId: IpcRequestId;
+ sourceClientId: string;
+ targetClientId?: string;
+ version: number;
+ method: string;
+ params?: JsonValue;
+ timeoutMs?: number;
+}
+
+export interface IpcResponse {
+ type: "response";
+ requestId: IpcRequestId;
+ resultType: "success" | "error";
+ method?: string;
+ handledByClientId?: string;
+ result?: JsonValue;
+ error?: string;
+}
+
+export interface IpcBroadcast {
+ type: "broadcast";
+ method: string;
+ sourceClientId?: string;
+ targetClientIds?: string[];
+ version: number;
+ params?: JsonValue;
+}
+
+export interface IpcClientDiscoveryRequest {
+ type: "client-discovery-request";
+ requestId: IpcRequestId;
+ request: IpcRequest;
+}
+
+export interface IpcClientDiscoveryResponse {
+ type: "client-discovery-response";
+ requestId: IpcRequestId;
+ response: { canHandle: boolean };
+}
+
+export type IpcMessage =
+ | IpcRequest
+ | IpcResponse
+ | IpcBroadcast
+ | IpcClientDiscoveryRequest
+ | IpcClientDiscoveryResponse;
+
+export interface IpcJsonPatch {
+ op: "add" | "remove" | "replace";
+ path: IpcPatchPathPart[];
+ value?: JsonValue;
+}
+
+export interface ThreadStreamSnapshot {
+ type: "snapshot";
+ revision: number;
+ conversationState: JsonObject;
+}
+
+export interface ThreadStreamPatches {
+ type: "patches";
+ baseRevision: number;
+ revision: number;
+ patches: IpcJsonPatch[];
+}
+
+export type ThreadStreamChange = ThreadStreamSnapshot | ThreadStreamPatches;
+
+export interface ConversationStreamState {
+ conversationId: string;
+ hostId: string;
+ ownerClientId: string;
+ revision: number;
+ conversationState: JsonObject;
+}
+
+export type ConversationStreamEvent =
+ | (ConversationStreamState & { kind: "snapshot"; raw: IpcBroadcast })
+ | (ConversationStreamState & { kind: "patches"; patches: IpcJsonPatch[]; baseRevision: number; raw: IpcBroadcast })
+ | {
+ kind: "desync";
+ conversationId: string;
+ hostId: string;
+ ownerClientId: string;
+ expectedRevision: number;
+ receivedBaseRevision: number;
+ receivedRevision: number;
+ raw: IpcBroadcast;
+ };
+
+export interface CodexIpcClientOptions {
+ /** Explicit socket path; otherwise `$CODEX_HOME/ipc/ipc.sock` or `~/.codex`. */
+ socketPath?: string;
+ codexHome?: string;
+ homeDir?: string;
+ env?: NodeJS.ProcessEnv;
+ platform?: NodeJS.Platform;
+ clientType?: string;
+ requestTimeoutMs?: number;
+ maxFrameBytes?: number;
+ strictVersions?: boolean;
+ /** Reconnect after a socket close and re-send all active following subscriptions. */
+ autoReconnect?: boolean;
+ reconnectDelayMs?: number;
+ /** Optional handler for discovery requests. Default is fail-closed (`false`). */
+ canHandleRequest?: (request: IpcRequest) => boolean | Promise;
+}
+
+export interface FollowerTurnStartOptions {
+ request?: JsonObject;
+ context?: JsonObject;
+ clientUserMessageId?: string;
+ ownerClientId?: string;
+ timeoutMs?: number;
+}
+
+export interface FollowerSteerOptions {
+ clientUserMessageId?: string;
+ serviceTier?: string | null;
+ attachments?: JsonValue[];
+ additionalContext?: JsonObject | null;
+ restoreMessage?: JsonValue | null;
+ ownerClientId?: string;
+ timeoutMs?: number;
+}
+
+export interface FollowerInterruptOptions {
+ mode?: "user-stop" | "system" | "descendant-cleanup" | string;
+ expectedTurnId?: string | null;
+ ownerClientId?: string;
+ timeoutMs?: number;
+}
+
+export interface FollowOptions {
+ hostId?: string;
+ targetClientIds?: string[];
+}
+
+export interface RequestOptions {
+ targetClientId?: string;
+ timeoutMs?: number;
+ version?: number;
+ requestId?: IpcRequestId;
+}
+
+export interface IpcErrorOptions {
+ code: string;
+ response?: IpcResponse;
+}
+
+export class CodexIpcError extends Error {
+ readonly code: string;
+ readonly response?: IpcResponse;
+
+ constructor(message: string, options: IpcErrorOptions) {
+ super(message);
+ this.name = "CodexIpcError";
+ this.code = options.code;
+ this.response = options.response;
+ }
+}
+
+export function resolveCodexIpcSocketPath(options: {
+ socketPath?: string;
+ codexHome?: string;
+ homeDir?: string;
+ env?: NodeJS.ProcessEnv;
+ platform?: NodeJS.Platform;
+} = {}): string {
+ if (options.socketPath?.trim()) return options.socketPath.trim();
+ const platform = options.platform ?? process.platform;
+ if (platform === "win32") return "\\\\.\\pipe\\codex-ipc";
+ const env = options.env ?? process.env;
+ const homeDir = options.homeDir ?? os.homedir();
+ const configuredHome = options.codexHome?.trim() || env.CODEX_HOME?.trim() || path.join(homeDir, ".codex");
+ const codexHome = configuredHome === "~"
+ ? homeDir
+ : configuredHome.startsWith("~/")
+ ? path.join(homeDir, configuredHome.slice(2))
+ : configuredHome;
+ return path.join(codexHome, "ipc", "ipc.sock");
+}
+
+/** Encode one private IPC frame: uint32 little-endian byte length + UTF-8 JSON. */
+export function encodeIpcFrame(message: IpcMessage, maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES): Buffer {
+ const json = JSON.stringify(message);
+ const payload = Buffer.from(json, "utf8");
+ if (payload.length === 0 || payload.length > maxFrameBytes) {
+ throw new RangeError(`IPC frame exceeds ${maxFrameBytes} bytes`);
+ }
+ const frame = Buffer.allocUnsafe(4 + payload.length);
+ frame.writeUInt32LE(payload.length, 0);
+ payload.copy(frame, 4);
+ return frame;
+}
+
+/** Incremental decoder that accepts arbitrary TCP/Unix-socket chunk boundaries. */
+export class IpcFrameDecoder {
+ private buffer = Buffer.alloc(0);
+
+ constructor(private readonly maxFrameBytes = DEFAULT_MAX_IPC_FRAME_BYTES) {}
+
+ push(chunk: Uint8Array): IpcMessage[] {
+ if (chunk.length === 0) return [];
+ this.buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk]);
+ const messages: IpcMessage[] = [];
+ while (this.buffer.length >= 4) {
+ const payloadLength = this.buffer.readUInt32LE(0);
+ if (payloadLength === 0 || payloadLength > this.maxFrameBytes) {
+ throw new CodexIpcError(`Invalid IPC frame length (${payloadLength} bytes)`, { code: "invalid-frame-length" });
+ }
+ if (this.buffer.length < payloadLength + 4) break;
+ const payload = this.buffer.subarray(4, payloadLength + 4).toString("utf8");
+ this.buffer = this.buffer.subarray(payloadLength + 4);
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(payload);
+ } catch (error) {
+ throw new CodexIpcError(`Invalid IPC JSON: ${error instanceof Error ? error.message : String(error)}`, {
+ code: "invalid-json",
+ });
+ }
+ if (!isRecord(decoded) || typeof decoded.type !== "string") {
+ throw new CodexIpcError("IPC frame must be an object with a type", { code: "invalid-message" });
+ }
+ messages.push(decoded as unknown as IpcMessage);
+ }
+ return messages;
+ }
+
+ reset(): void {
+ this.buffer = Buffer.alloc(0);
+ }
+}
+
+type Listener = (value: T) => void;
+export interface IpcSubscription { dispose(): void; }
+
+function subscribe(set: Set>, listener: Listener): IpcSubscription {
+ set.add(listener);
+ return { dispose: () => set.delete(listener) };
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isJsonObject(value: unknown): value is JsonObject {
+ return isRecord(value);
+}
+
+function requestIdKey(id: IpcRequestId): string {
+ return `${typeof id}:${String(id)}`;
+}
+
+function cloneJson(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
+function versionFor(method: string, params?: JsonValue): number {
+ // The official client accepts interrupt v3 when expectedTurnId is absent;
+ // v4 is used when the active-turn precondition is present.
+ if (method === "thread-follower-interrupt-turn"
+ && (!isRecord(params) || params.expectedTurnId === undefined || params.expectedTurnId === null)) return 3;
+ return CODEX_IPC_METHOD_VERSIONS[method as IpcMethod] ?? 0;
+}
+
+function textInput(text: string): JsonObject {
+ return { type: "text", text, text_elements: [] };
+}
+
+function normalizeInput(input: string | JsonValue[]): JsonValue[] {
+ return typeof input === "string"
+ ? [textInput(input)]
+ : input.map((entry) => typeof entry === "string" ? textInput(entry) : entry);
+}
+
+function hasTarget(frame: IpcBroadcast, clientId: string): boolean {
+ return frame.targetClientIds == null || frame.targetClientIds.includes(clientId);
+}
+
+/** Apply the JSON patch arrays generated by Immer in the official webview. */
+export function applyIpcPatches(root: JsonValue, patches: IpcJsonPatch[]): JsonValue {
+ let result = cloneJson(root);
+ for (const patch of patches) {
+ if (!Array.isArray(patch.path)) throw new CodexIpcError("IPC patch path must be an array", { code: "invalid-patch" });
+ if (patch.path.length === 0) {
+ if (patch.op === "remove") throw new CodexIpcError("Removing the conversation root is unsupported", { code: "invalid-patch" });
+ if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
+ result = cloneJson(patch.value);
+ continue;
+ }
+
+ const parentPath = patch.path.slice(0, -1);
+ const key = patch.path[patch.path.length - 1];
+ assertSafePatchPart(key);
+ const parent = getAtPath(result, parentPath);
+ if (Array.isArray(parent)) {
+ const index = key === "-" ? parent.length : toArrayIndex(key);
+ if (patch.op === "add") {
+ if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
+ parent.splice(index, 0, cloneJson(patch.value));
+ } else if (patch.op === "replace") {
+ if (patch.value === undefined || index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array replace patch", { code: "invalid-patch" });
+ parent[index] = cloneJson(patch.value);
+ } else {
+ if (index < 0 || index >= parent.length) throw new CodexIpcError("Invalid array remove patch", { code: "invalid-patch" });
+ parent.splice(index, 1);
+ }
+ continue;
+ }
+ if (!isRecord(parent) || typeof key !== "string") {
+ throw new CodexIpcError("IPC patch parent is not an object or array", { code: "invalid-patch" });
+ }
+ if (patch.op === "remove") {
+ delete parent[key];
+ } else {
+ if (patch.value === undefined) throw new CodexIpcError("Patch value is missing", { code: "invalid-patch" });
+ parent[key] = cloneJson(patch.value);
+ }
+ }
+ return result;
+}
+
+function getAtPath(root: JsonValue, pathParts: IpcPatchPathPart[]): JsonValue {
+ let current: JsonValue = root;
+ for (const part of pathParts) {
+ if (Array.isArray(current)) {
+ const index = toArrayIndex(part);
+ if (index < 0 || index >= current.length) throw new CodexIpcError("IPC patch path is out of bounds", { code: "invalid-patch" });
+ current = current[index];
+ } else if (isRecord(current) && typeof part === "string" && Object.prototype.hasOwnProperty.call(current, part)) {
+ assertSafePatchPart(part);
+ current = current[part];
+ } else {
+ throw new CodexIpcError("IPC patch path does not exist", { code: "invalid-patch" });
+ }
+ }
+ return current;
+}
+
+function toArrayIndex(value: IpcPatchPathPart): number {
+ if (typeof value === "number" && Number.isInteger(value)) return value;
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
+ throw new CodexIpcError(`Invalid array patch index: ${String(value)}`, { code: "invalid-patch" });
+}
+
+function assertSafePatchPart(value: IpcPatchPathPart): void {
+ if (value === "__proto__" || value === "prototype" || value === "constructor") {
+ throw new CodexIpcError("Unsafe IPC patch path", { code: "invalid-patch" });
+ }
+}
+
+export class CodexIpcClient {
+ readonly socketPath: string;
+ private readonly options: Required> & CodexIpcClientOptions;
+ private socket: net.Socket | undefined;
+ private decoder: IpcFrameDecoder;
+ private connectPromise: Promise | undefined;
+ private reconnectTimer: NodeJS.Timeout | undefined;
+ private disposed = false;
+ private clientId = INITIALIZING_CLIENT_ID;
+ private readonly pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
+ private readonly followed = new Map();
+ private readonly streams = new Map();
+ private readonly messageListeners = new Set>();
+ private readonly broadcastListeners = new Set>();
+ private readonly streamListeners = new Set>();
+ private readonly errorListeners = new Set>();
+ private readonly closeListeners = new Set>();
+ private readonly discoveryHandler?: (request: IpcRequest) => boolean | Promise;
+
+ constructor(options: CodexIpcClientOptions = {}) {
+ this.options = {
+ ...options,
+ clientType: options.clientType ?? "codex-remote-collab",
+ requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_IPC_REQUEST_TIMEOUT_MS,
+ maxFrameBytes: options.maxFrameBytes ?? DEFAULT_MAX_IPC_FRAME_BYTES,
+ strictVersions: options.strictVersions ?? true,
+ autoReconnect: options.autoReconnect ?? false,
+ reconnectDelayMs: options.reconnectDelayMs ?? 1_000,
+ };
+ this.socketPath = resolveCodexIpcSocketPath(options);
+ this.decoder = new IpcFrameDecoder(this.options.maxFrameBytes);
+ this.discoveryHandler = options.canHandleRequest;
+ }
+
+ getClientId(): string { return this.clientId; }
+
+ getConversationState(conversationId: string): ConversationStreamState | undefined {
+ const state = this.streams.get(conversationId);
+ return state == null ? undefined : { ...state, conversationState: cloneJson(state.conversationState) };
+ }
+
+ getFollowedConversations(): ReadonlyMap { return this.followed; }
+
+ onMessage(listener: Listener): IpcSubscription { return subscribe(this.messageListeners, listener); }
+ onBroadcast(listener: Listener): IpcSubscription { return subscribe(this.broadcastListeners, listener); }
+ onStreamEvent(listener: Listener): IpcSubscription { return subscribe(this.streamListeners, listener); }
+ onError(listener: Listener): IpcSubscription { return subscribe(this.errorListeners, listener); }
+ onClose(listener: Listener): IpcSubscription { return subscribe(this.closeListeners, listener); }
+
+ async connect(): Promise {
+ if (this.disposed) throw new CodexIpcError("IPC client is disposed", { code: "disposed" });
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = undefined;
+ }
+ if (this.socket?.writable && this.clientId !== INITIALIZING_CLIENT_ID) return this.clientId;
+ if (this.connectPromise) return this.connectPromise;
+ this.connectPromise = new Promise((resolve, reject) => {
+ const socket = net.createConnection(this.socketPath);
+ this.socket = socket;
+ this.decoder.reset();
+ let settled = false;
+ const finishError = (error: Error): void => {
+ if (!settled) {
+ settled = true;
+ reject(error);
+ }
+ this.emitError(error);
+ };
+ socket.setNoDelay?.(true);
+ socket.on("connect", () => {
+ const requestId = crypto.randomUUID();
+ const timer = setTimeout(() => {
+ this.pending.delete(requestIdKey(requestId));
+ finishError(new CodexIpcError("IPC initialize timed out", { code: "timeout" }));
+ socket.destroy();
+ }, this.options.requestTimeoutMs);
+ this.pending.set(requestIdKey(requestId), {
+ method: "initialize",
+ resolve: (response) => {
+ clearTimeout(timer);
+ if (response.resultType !== "success" || !isRecord(response.result) || typeof response.result.clientId !== "string") {
+ finishError(new CodexIpcError("IPC initialize returned an invalid response", { code: "initialize-failed", response }));
+ socket.destroy();
+ return;
+ }
+ this.clientId = response.result.clientId;
+ settled = true;
+ resolve(this.clientId);
+ this.resubscribeAfterConnect().catch((error) => this.emitError(asError(error)));
+ },
+ reject: (error) => {
+ clearTimeout(timer);
+ finishError(error);
+ socket.destroy();
+ },
+ timer,
+ });
+ this.write({
+ type: "request",
+ requestId,
+ sourceClientId: INITIALIZING_CLIENT_ID,
+ version: 0,
+ method: "initialize",
+ params: { clientType: this.options.clientType },
+ });
+ });
+ socket.on("data", (chunk) => {
+ try {
+ for (const message of this.decoder.push(chunk)) this.handleMessage(message);
+ } catch (error) {
+ const normalized = asError(error);
+ finishError(normalized);
+ socket.destroy(normalized);
+ }
+ });
+ socket.on("error", (error) => {
+ if (!settled) finishError(error);
+ else this.emitError(error);
+ });
+ socket.on("close", () => {
+ this.handleClose();
+ });
+ }).finally(() => {
+ this.connectPromise = undefined;
+ });
+ return this.connectPromise;
+ }
+
+ async followConversation(conversationId: string, following = true, options: FollowOptions = {}): Promise {
+ const hostId = options.hostId ?? "local";
+ await this.connect();
+ if (following) this.followed.set(conversationId, hostId);
+ else {
+ this.followed.delete(conversationId);
+ this.streams.delete(conversationId);
+ }
+ const params: JsonObject = { conversationId, hostId, following };
+ const frame: IpcBroadcast = {
+ type: "broadcast",
+ method: "thread-stream-following-changed",
+ sourceClientId: this.clientId,
+ version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
+ params,
+ };
+ if (options.targetClientIds) frame.targetClientIds = options.targetClientIds;
+ this.write(frame);
+ }
+
+ async findThreadOwner(conversationId: string, hostId = "local", timeoutMs = this.options.requestTimeoutMs): Promise {
+ try {
+ const response = await this.request("thread-owner-discovery", { conversationId, hostId }, { timeoutMs });
+ return response.handledByClientId ?? null;
+ } catch (error) {
+ if (error instanceof CodexIpcError
+ && (error.code === "no-client-found" || error.code.startsWith("no-client-found:"))) return null;
+ throw error;
+ }
+ }
+
+ async request(method: string, params?: JsonValue, options: RequestOptions = {}): Promise {
+ await this.connect();
+ const requestId = options.requestId ?? crypto.randomUUID();
+ const timeoutMs = options.timeoutMs ?? this.options.requestTimeoutMs;
+ const frame: IpcRequest = {
+ type: "request",
+ requestId,
+ sourceClientId: this.clientId,
+ version: options.version ?? versionFor(method, params),
+ method,
+ params,
+ };
+ if (options.targetClientId) frame.targetClientId = options.targetClientId;
+ if (timeoutMs > 0) frame.timeoutMs = timeoutMs;
+ return new Promise((resolve, reject) => {
+ const key = requestIdKey(requestId);
+ const timer = setTimeout(() => {
+ this.pending.delete(key);
+ reject(new CodexIpcError(`${method} timed out`, { code: "timeout" }));
+ }, timeoutMs > 0 ? timeoutMs : 2 ** 31 - 1);
+ this.pending.set(key, { method, resolve, reject, timer });
+ try {
+ this.write(frame);
+ } catch (error) {
+ clearTimeout(timer);
+ this.pending.delete(key);
+ reject(asError(error));
+ }
+ }).then((response) => {
+ if (response.resultType === "error") {
+ throw new CodexIpcError(response.error ?? `${method} failed`, { code: response.error ?? "ipc-error", response });
+ }
+ if (response.method != null && response.method !== method) {
+ throw new CodexIpcError(`IPC response method mismatch: expected ${method}, got ${response.method}`, {
+ code: "response-method-mismatch",
+ response,
+ });
+ }
+ return response;
+ });
+ }
+
+ async requestFollower(method: string, conversationId: string, params: JsonObject = {}, options: RequestOptions & { ownerClientId?: string } = {}): Promise {
+ const ownerClientId = options.ownerClientId ?? this.streams.get(conversationId)?.ownerClientId;
+ if (!ownerClientId) throw new CodexIpcError(`No owner is known for conversation ${conversationId}`, { code: "owner-unknown" });
+ // Do not allow a caller-provided params object to accidentally retarget a
+ // request after the owner has been selected from the stream snapshot.
+ const body: JsonObject = { ...params, conversationId };
+ const { ownerClientId: _owner, ...requestOptions } = options;
+ return this.request(method, body, { ...requestOptions, targetClientId: ownerClientId });
+ }
+
+ /** Send the exact private `turnStart` envelope expected by the owner. */
+ async startTurn(conversationId: string, input: string | JsonValue[], options: FollowerTurnStartOptions = {}): Promise {
+ const request: JsonObject = {
+ ...(options.request ?? {}),
+ threadId: conversationId,
+ input: options.request?.input ?? normalizeInput(input),
+ };
+ const context: JsonObject = { inheritThreadSettings: true, ...(options.context ?? {}) };
+ if (options.clientUserMessageId) request.clientUserMessageId = options.clientUserMessageId;
+ const response = await this.requestFollower("thread-follower-start-turn", conversationId, {
+ turnStart: { request, context },
+ }, {
+ ownerClientId: options.ownerClientId,
+ timeoutMs: options.timeoutMs,
+ });
+ return response.result;
+ }
+
+ async steerTurn(conversationId: string, input: string | JsonValue[], options: FollowerSteerOptions = {}): Promise {
+ const params: JsonObject = {
+ clientUserMessageId: options.clientUserMessageId ?? crypto.randomUUID(),
+ input: normalizeInput(input),
+ attachments: options.attachments ?? [],
+ };
+ if (options.serviceTier !== undefined) params.serviceTier = options.serviceTier;
+ if (options.additionalContext !== undefined) params.additionalContext = options.additionalContext;
+ if (options.restoreMessage !== undefined) params.restoreMessage = options.restoreMessage;
+ const response = await this.requestFollower("thread-follower-steer-turn", conversationId, params, {
+ ownerClientId: options.ownerClientId,
+ timeoutMs: options.timeoutMs,
+ });
+ return response.result;
+ }
+
+ /**
+ * Persist settings for the next turn through the official conversation
+ * owner. The owner-side follower handler expects the settings nested under
+ * `threadSettings`; `requestFollower` adds the conversation id to the
+ * outer envelope, yielding:
+ * `{ conversationId, threadSettings }`.
+ */
+ async updateThreadSettings(
+ conversationId: string,
+ threadSettings: JsonObject,
+ options: RequestOptions & { ownerClientId?: string } = {},
+ ): Promise {
+ if (!isJsonObject(threadSettings)) {
+ throw new CodexIpcError("thread settings must be a JSON object", { code: "invalid-thread-settings" });
+ }
+ const response = await this.requestFollower(
+ "thread-follower-update-thread-settings",
+ conversationId,
+ { threadSettings: cloneJson(threadSettings) },
+ options,
+ );
+ return response.result;
+ }
+
+ /** Alias matching the official app-server manager method name. */
+ async updateThreadSettingsForNextTurn(
+ conversationId: string,
+ threadSettings: JsonObject,
+ options: RequestOptions & { ownerClientId?: string } = {},
+ ): Promise {
+ return this.updateThreadSettings(conversationId, threadSettings, options);
+ }
+
+ async interruptTurn(conversationId: string, options: FollowerInterruptOptions = {}): Promise {
+ const params: JsonObject = { mode: options.mode ?? "user-stop" };
+ if (options.expectedTurnId !== undefined && options.expectedTurnId !== null) params.expectedTurnId = options.expectedTurnId;
+ const response = await this.requestFollower("thread-follower-interrupt-turn", conversationId, params, {
+ ownerClientId: options.ownerClientId,
+ timeoutMs: options.timeoutMs,
+ });
+ return response.result;
+ }
+
+ async loadCompleteHistory(conversationId: string, options: RequestOptions & { ownerClientId?: string } = {}): Promise {
+ const response = await this.requestFollower("thread-follower-load-complete-history", conversationId, {}, options);
+ return response.result;
+ }
+
+ async respondCommandApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise {
+ return this.respondFollower("thread-follower-command-approval-decision", conversationId, { requestId, decision }, options);
+ }
+
+ async respondFileApproval(conversationId: string, requestId: IpcRequestId, decision: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise {
+ return this.respondFollower("thread-follower-file-approval-decision", conversationId, { requestId, decision }, options);
+ }
+
+ async respondPermissionsApproval(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise {
+ return this.respondFollower("thread-follower-permissions-request-approval-response", conversationId, { requestId, response }, options);
+ }
+
+ async respondUserInput(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise {
+ return this.respondFollower("thread-follower-submit-user-input", conversationId, { requestId, response }, options);
+ }
+
+ async respondMcpElicitation(conversationId: string, requestId: IpcRequestId, response: JsonValue, options: RequestOptions & { ownerClientId?: string } = {}): Promise {
+ return this.respondFollower("thread-follower-submit-mcp-server-elicitation-response", conversationId, { requestId, response }, options);
+ }
+
+ private async respondFollower(method: string, conversationId: string, params: JsonObject, options: RequestOptions & { ownerClientId?: string }): Promise {
+ const response = await this.requestFollower(method, conversationId, params, options);
+ return response.result;
+ }
+
+ async dispose(): Promise {
+ this.disposed = true;
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = undefined;
+ for (const pending of this.pending.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(new CodexIpcError("IPC client disposed", { code: "disposed" }));
+ }
+ this.pending.clear();
+ this.socket?.destroy();
+ this.socket = undefined;
+ this.clientId = INITIALIZING_CLIENT_ID;
+ }
+
+ private write(message: IpcMessage): void {
+ if (!this.socket?.writable) throw new CodexIpcError("IPC socket is not connected", { code: "not-connected" });
+ this.socket.write(encodeIpcFrame(message, this.options.maxFrameBytes));
+ }
+
+ private handleMessage(message: IpcMessage): void {
+ for (const listener of this.messageListeners) safeCall(listener, message, (error) => this.emitError(error));
+ switch (message.type) {
+ case "response":
+ this.handleResponse(message);
+ return;
+ case "broadcast":
+ this.handleBroadcast(message);
+ return;
+ case "client-discovery-request":
+ this.handleDiscoveryRequest(message).catch((error) => this.emitError(asError(error)));
+ return;
+ case "request":
+ this.handleUnexpectedRequest(message);
+ return;
+ case "client-discovery-response":
+ // Discovery responses are consumed by the router, not by clients.
+ return;
+ }
+ }
+
+ private handleResponse(response: IpcResponse): void {
+ const key = requestIdKey(response.requestId);
+ const pending = this.pending.get(key);
+ if (!pending) return;
+ this.pending.delete(key);
+ clearTimeout(pending.timer);
+ pending.resolve(response);
+ }
+
+ private handleBroadcast(frame: IpcBroadcast): void {
+ if (!hasTarget(frame, this.clientId)) return;
+ for (const listener of this.broadcastListeners) safeCall(listener, frame, (error) => this.emitError(error));
+ if (frame.method === "thread-stream-state-changed") {
+ this.handleStreamStateBroadcast(frame);
+ } else if (frame.method === "thread-stream-following-status-requested") {
+ this.handleFollowingStatusRequested(frame);
+ }
+ }
+
+ /** Re-announce active subscriptions when an owner reconnects or hands off. */
+ private handleFollowingStatusRequested(frame: IpcBroadcast): void {
+ if (!isRecord(frame.params)) return;
+ if (this.options.strictVersions
+ && frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-following-status-requested"]) {
+ this.emitError(new CodexIpcError(`Unsupported thread following status version ${frame.version}`, { code: "version-mismatch" }));
+ return;
+ }
+ const conversationId = typeof frame.params.conversationId === "string"
+ ? frame.params.conversationId
+ : undefined;
+ const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
+ const requester = frame.sourceClientId;
+ if (!conversationId || !requester || requester === this.clientId) return;
+ if (this.followed.get(conversationId) !== hostId) return;
+ void this.followConversation(conversationId, true, {
+ hostId,
+ targetClientIds: [requester],
+ }).catch((error) => this.emitError(asError(error)));
+ }
+
+ private handleStreamStateBroadcast(frame: IpcBroadcast): void {
+ if (!isRecord(frame.params)) return;
+ const conversationId = typeof frame.params.conversationId === "string" ? frame.params.conversationId : undefined;
+ const hostId = typeof frame.params.hostId === "string" ? frame.params.hostId : "local";
+ const change = frame.params.change;
+ if (!conversationId || !isRecord(change) || typeof change.type !== "string") return;
+ if (this.options.strictVersions && frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-state-changed"]) {
+ this.emitError(new CodexIpcError(`Unsupported thread stream version ${frame.version}`, { code: "version-mismatch" }));
+ return;
+ }
+ const ownerClientId = frame.sourceClientId ?? "";
+ if (change.type === "snapshot") {
+ if (typeof change.revision !== "number" || !isJsonObject(change.conversationState)) return;
+ const state: ConversationStreamState = {
+ conversationId,
+ hostId,
+ ownerClientId,
+ revision: change.revision,
+ conversationState: cloneJson(change.conversationState),
+ };
+ this.streams.set(conversationId, state);
+ this.emitStream({ kind: "snapshot", ...state, raw: frame });
+ return;
+ }
+ if (change.type !== "patches" || typeof change.baseRevision !== "number" || typeof change.revision !== "number" || !Array.isArray(change.patches)) return;
+ const current = this.streams.get(conversationId);
+ if (!current || current.ownerClientId !== ownerClientId || current.revision !== change.baseRevision) {
+ const expectedRevision = current?.revision ?? 0;
+ this.emitStream({
+ kind: "desync",
+ conversationId,
+ hostId,
+ ownerClientId,
+ expectedRevision,
+ receivedBaseRevision: change.baseRevision,
+ receivedRevision: change.revision,
+ raw: frame,
+ });
+ // Re-sending `following:true` is how the official follower asks the
+ // owner for a fresh snapshot when a patch base revision is missed.
+ if (this.followed.has(conversationId)) {
+ this.followConversation(conversationId, true, { hostId }).catch((error) => this.emitError(asError(error)));
+ }
+ return;
+ }
+ try {
+ const patches = change.patches as unknown as IpcJsonPatch[];
+ const nextConversationState = applyIpcPatches(current.conversationState, patches);
+ if (!isJsonObject(nextConversationState)) throw new CodexIpcError("Patched conversation state is not an object", { code: "invalid-patch" });
+ const next: ConversationStreamState = {
+ ...current,
+ revision: change.revision,
+ conversationState: nextConversationState,
+ };
+ this.streams.set(conversationId, next);
+ this.emitStream({ kind: "patches", ...next, patches, baseRevision: change.baseRevision, raw: frame });
+ } catch (error) {
+ this.emitError(asError(error));
+ }
+ }
+
+ private async handleDiscoveryRequest(message: IpcClientDiscoveryRequest): Promise {
+ const request = message.request;
+ let canHandle = false;
+ try {
+ canHandle = this.discoveryHandler ? await this.discoveryHandler(request) : false;
+ } catch {
+ canHandle = false;
+ }
+ this.write({
+ type: "client-discovery-response",
+ requestId: message.requestId,
+ response: { canHandle },
+ });
+ }
+
+ private handleUnexpectedRequest(request: IpcRequest): void {
+ try {
+ this.write({
+ type: "response",
+ requestId: request.requestId,
+ resultType: "error",
+ error: "no-handler-for-request",
+ });
+ } catch (error) {
+ this.emitError(asError(error));
+ }
+ }
+
+ private async resubscribeAfterConnect(): Promise {
+ const subscriptions = [...this.followed.entries()];
+ for (const [conversationId, hostId] of subscriptions) {
+ this.write({
+ type: "broadcast",
+ method: "thread-stream-following-changed",
+ sourceClientId: this.clientId,
+ version: CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"],
+ params: { conversationId, hostId, following: true },
+ });
+ }
+ }
+
+ private handleClose(): void {
+ const socket = this.socket;
+ this.socket = undefined;
+ this.decoder.reset();
+ const closeError = new CodexIpcError("IPC socket closed", { code: "connection-closed" });
+ for (const pending of this.pending.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(closeError);
+ }
+ this.pending.clear();
+ this.clientId = INITIALIZING_CLIENT_ID;
+ for (const listener of this.closeListeners) safeCall(listener, closeError, (error) => this.emitError(error));
+ if (!this.disposed && this.options.autoReconnect && socket) {
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = undefined;
+ this.connect().catch((error) => this.emitError(asError(error)));
+ }, this.options.reconnectDelayMs);
+ }
+ }
+
+ private emitStream(event: ConversationStreamEvent): void {
+ for (const listener of this.streamListeners) safeCall(listener, event, (error) => this.emitError(error));
+ }
+
+ private emitError(error: Error): void {
+ for (const listener of this.errorListeners) safeCall(listener, error, () => undefined);
+ }
+}
+
+function safeCall(listener: Listener, value: T, onError: (error: Error) => void): void {
+ try {
+ listener(value);
+ } catch (error) {
+ onError(asError(error));
+ }
+}
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
diff --git a/aether-vscodex/vscode-extension/src/codexIpcAgentAdapter.ts b/aether-vscodex/vscode-extension/src/codexIpcAgentAdapter.ts
new file mode 100644
index 000000000..e7fbd3f18
--- /dev/null
+++ b/aether-vscodex/vscode-extension/src/codexIpcAgentAdapter.ts
@@ -0,0 +1,4253 @@
+import { createHash, randomUUID } from "node:crypto";
+import { promises as fs } from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+import {
+ CODEX_IPC_METHOD_VERSIONS,
+ CodexIpcClient,
+ CodexIpcClientOptions,
+ ConversationStreamState,
+ ConversationStreamEvent,
+ IpcBroadcast,
+ IpcSubscription,
+} from "./codexIpc";
+import {
+ AgentAdapter,
+ AgentEvent,
+ AgentStatusSnapshot,
+ asJsonObject,
+ asJsonValue,
+ Disposable,
+ isJsonRpcId,
+ isRecord,
+ JsonObject,
+ JsonRpcId,
+ JsonValue,
+ Logger,
+ PendingApproval,
+ SessionSnapshot,
+ SessionListEntry,
+ SessionListResult,
+ SubagentSnapshot,
+ jsonRpcIdKey,
+} from "./protocol";
+
+const APPROVAL_METHODS = new Set([
+ "item/commandExecution/requestApproval",
+ "item/fileChange/requestApproval",
+ "item/permissions/requestApproval",
+ "applyPatchApproval",
+ "execCommandApproval",
+]);
+const INPUT_METHODS = new Set([
+ "item/tool/requestUserInput",
+ "mcpServer/elicitation/request",
+]);
+const TERMINAL_TURN_STATES = new Set([
+ "completed",
+ "complete",
+ "failed",
+ "cancelled",
+ "canceled",
+ "interrupted",
+ "error",
+ "done",
+]);
+const HISTORY_LOAD_RETRY_DELAY_MS = 50;
+const VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS = 250;
+const VSCODE_SESSION_FOLLOW_MAX_ATTEMPTS = 3;
+/** Keep the relay alive while a user opens/selects the first official panel. */
+const WAITING_SESSION_DISCOVERY_DELAY_MS = 1_000;
+/** A waiting poll is a fallback; route broadcasts handle the common fast path. */
+const WAITING_SESSION_DISCOVERY_MAX_CANDIDATES = 8;
+
+export interface CodexIpcAgentAdapterOptions extends Omit {
+ /** Existing conversation id. Empty/undefined enables local discovery. */
+ threadId?: string;
+ hostId?: string;
+ autoDiscoverThread?: boolean;
+ /** Workspace paths used to rank auto-discovered sessions. */
+ preferredCwds?: string[];
+ /** Ask the owner for older paginated history after the initial snapshot. */
+ loadCompleteHistory?: boolean;
+ /** Follow route changes in the official VS Code Codex panel. */
+ followVscodeSession?: boolean;
+ /** Coalesce the official panel's old=false/new=true route broadcasts. */
+ vscodeSessionFollowDebounceMs?: number;
+ ownerDiscoveryTimeoutMs?: number;
+ followTimeoutMs?: number;
+ maxOutputTailChars?: number;
+ approvalTimeoutMs?: number;
+ logger?: Logger;
+ /** Invoke the existing official VS Code command for a fresh Codex panel. */
+ openNewSession?: () => Promise;
+ /** Inject a client in tests. The adapter owns it unless disabled below. */
+ client?: CodexIpcClient;
+ disposeClient?: boolean;
+}
+
+interface RequestEntry {
+ requestId: JsonRpcId;
+ method: string;
+ params: JsonObject;
+ threadId?: string;
+ turnId?: string;
+ createdAt: number;
+ expiresAt?: number;
+ approval?: PendingApproval;
+}
+
+interface TurnInfo {
+ id?: string;
+ status: string;
+ active: boolean;
+ startedAt?: number;
+ durationMs?: number | null;
+ workedDurationMs?: number | null;
+ firstTurnWorkItemStartedAtMs?: number | null;
+ finalAssistantStartedAtMs?: number | null;
+ completedAtMs?: number | null;
+ error?: JsonValue;
+ /** The raw turn record, used to classify the currently running work item. */
+ raw?: Record;
+}
+
+/**
+ * AgentAdapter backed by the private IPC follower protocol used by the
+ * official OpenAI Codex VS Code extension. It never starts or kills a codex
+ * process: all work is routed to the owner of an already-open conversation.
+ */
+export class CodexIpcAgentAdapter implements AgentAdapter {
+ private readonly options: Required> & CodexIpcAgentAdapterOptions;
+ private readonly client: CodexIpcClient;
+ private readonly listeners = new Set<(event: AgentEvent) => void>();
+ private readonly subscriptions: IpcSubscription[] = [];
+ private readonly pending = new Map();
+ private readonly pendingTimers = new Map();
+ private readonly pendingExpiryAt = new Map();
+ private readonly optimisticallyResolved = new Set();
+ private threadId: string | null = null;
+ private ownerClientId: string | null = null;
+ private revision: number | null = null;
+ private conversationState: JsonObject = {};
+ private turnId: string | null = null;
+ private state = "disconnected";
+ private status: AgentStatusSnapshot = {
+ activity: "idle",
+ turnStatus: "idle",
+ activeFlags: [],
+ startedAtMs: null,
+ durationMs: null,
+ workedDurationMs: null,
+ elapsedMs: null,
+ firstTurnWorkItemStartedAtMs: null,
+ finalAssistantStartedAtMs: null,
+ };
+ private started = false;
+ private renderedOutput = "";
+ private renderedOutputLength = 0;
+ private renderedOutputWasTruncated = false;
+ private renderedMessageShape = "";
+ private outputTail = "";
+ private outputMessages: RenderedConversationMessage[] = [];
+ private subagents: SubagentSnapshot[] = [];
+ private renderedSubagentShape = "";
+ /** Fingerprint of the display-safe metadata projection last sent to peers. */
+ private renderedMetadataShape = "";
+ private snapshotSeen = false;
+ private historyComplete?: boolean;
+ private historyLoadRequested = false;
+ private historyLoadAttempts = 0;
+ private historyLoadGeneration = 0;
+ private historyLoadRetryTimer?: NodeJS.Timeout;
+ private disposed = false;
+ /** Serialize session navigation so two browser clicks cannot overlap. */
+ private sessionSwitching = false;
+ /** Invalidates in-flight navigation when dispose/socket close begins. */
+ private sessionLifecycleGeneration = 0;
+ /** IPC client that owns the official Codex panel route being mirrored. */
+ private vscodeRouteClientId: string | null = null;
+ /** Untrusted old=false halves; only a matching same-source true can bind. */
+ private readonly vscodeRouteCandidates = new Map();
+ /** Last route that source reported as active, independent of our attachment. */
+ private vscodeRouteActiveThreadId: string | null = null;
+ /** Official routing emits old=false before new=true, even when the picker stays open. */
+ private vscodeRouteAwaitingSelection = false;
+ /** Latest route selected in that official panel, coalesced across rapid clicks. */
+ private pendingVscodeThreadId: string | null = null;
+ private pendingVscodeFollowAttempts = 0;
+ private pendingVscodeFollowGeneration = 0;
+ private vscodeRouteGeneration = 0;
+ private activeVscodeSelection?: { target: string; generation: number };
+ private vscodeSessionFollowTimer?: NodeJS.Timeout;
+ /** Serialize attachability probes with navigation so probe cleanup cannot unfollow a newly selected thread. */
+ private sessionOperationTail: Promise = Promise.resolve();
+ /** True while the IPC/relay host is usable but no conversation is attached. */
+ private waitingForSession = false;
+ private waitingDiscoveryTimer?: NodeJS.Timeout;
+ private waitingDiscoveryInFlight?: Promise;
+ private waitingAttachPromise?: Promise;
+ private waitingAttachTarget: string | null = null;
+ private queuedWaitingAttachTarget: string | null = null;
+ private snapshotWaiter?: { threadId: string; ownerClientId?: string; resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout };
+ private revisionWaiter?: { threadId: string; ownerClientId: string; revision: number; resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout };
+
+ constructor(options: CodexIpcAgentAdapterOptions = {}) {
+ this.options = {
+ ...options,
+ hostId: options.hostId ?? "local",
+ autoDiscoverThread: options.autoDiscoverThread ?? true,
+ followVscodeSession: options.followVscodeSession ?? true,
+ vscodeSessionFollowDebounceMs: Math.max(0, options.vscodeSessionFollowDebounceMs ?? 150),
+ // Owner discovery for an active local VS Code client normally returns
+ // in a few milliseconds. A short default keeps stale rollout files from
+ // making bridge startup look hung; callers can raise this explicitly.
+ ownerDiscoveryTimeoutMs: options.ownerDiscoveryTimeoutMs ?? 2_500,
+ followTimeoutMs: options.followTimeoutMs ?? 8_000,
+ maxOutputTailChars: options.maxOutputTailChars ?? 32_000,
+ approvalTimeoutMs: options.approvalTimeoutMs ?? 5 * 60_000,
+ disposeClient: options.disposeClient ?? true,
+ };
+ this.client = options.client ?? new CodexIpcClient({
+ ...options,
+ clientType: "codex-remote-collab-follower",
+ canHandleRequest: () => false,
+ autoReconnect: false,
+ });
+ this.subscriptions.push(this.client.onBroadcast((frame) => this.handleBroadcast(frame)));
+ this.subscriptions.push(this.client.onStreamEvent((event) => this.handleStreamEvent(event)));
+ this.subscriptions.push(this.client.onError((error) => this.options.logger?.debug?.("Codex IPC error", error.message)));
+ this.subscriptions.push(this.client.onClose((error) => this.handleClose(error)));
+ }
+
+ async start(): Promise {
+ if (this.started) return;
+ if (this.disposed) throw new Error("Codex IPC follower is disposed");
+ this.clearWaitingDiscoveryTimer();
+ this.waitingForSession = false;
+ this.resetHistoryLoading();
+ this.renderedOutput = "";
+ this.renderedOutputLength = 0;
+ this.renderedOutputWasTruncated = false;
+ this.renderedMessageShape = "";
+ this.outputTail = "";
+ this.outputMessages = [];
+ this.subagents = [];
+ this.renderedSubagentShape = "";
+ this.renderedMetadataShape = "";
+ const configured = this.options.threadId?.trim();
+ await this.client.connect();
+ if (configured) {
+ try {
+ await this.attachThread(configured);
+ return;
+ } catch (error) {
+ // A remembered thread can belong to another Codex window (or to a
+ // previous run). When auto-discovery is enabled, keep startup useful
+ // by falling back to the most recent live VS Code owner.
+ if (!isMissingSessionOwnerError(error)) throw error;
+ if (!this.options.autoDiscoverThread) {
+ this.enterWaitingForSession();
+ return;
+ }
+ this.options.logger?.warn?.(`Configured Codex conversation ${configured} could not be followed; trying auto-discovery`, error);
+ const fallback = await this.discoverThreadId(new Set([configured]));
+ if (fallback) {
+ try {
+ await this.attachThread(fallback);
+ return;
+ } catch (fallbackError) {
+ if (!isMissingSessionOwnerError(fallbackError)) throw fallbackError;
+ }
+ }
+ this.enterWaitingForSession();
+ return;
+ }
+ }
+
+ const selectedThread = await this.discoverThreadId();
+ if (!selectedThread) {
+ // A fresh VS Code window can have a live IPC socket before the user has
+ // opened a Codex conversation. Keep the follower (and therefore the
+ // relay/WebSocket) alive so the first later panel navigation can attach
+ // without restarting the bridge.
+ this.enterWaitingForSession();
+ return;
+ }
+ await this.attachThread(selectedThread);
+ }
+
+ private async attachThread(selectedThread: string, options: { fromWaiting?: boolean } = {}): Promise {
+ const fromWaiting = options.fromWaiting === true;
+ const lifecycleGeneration = this.sessionLifecycleGeneration;
+ const routeGeneration = this.vscodeRouteGeneration;
+ const wasStarted = this.started;
+ const wasWaiting = this.waitingForSession;
+ const previousRouteClientId = this.vscodeRouteClientId;
+ const previousRouteActiveThreadId = this.vscodeRouteActiveThreadId;
+ const previousRouteAwaitingSelection = this.vscodeRouteAwaitingSelection;
+ const owner = await this.client.findThreadOwner(selectedThread, this.options.hostId, this.options.ownerDiscoveryTimeoutMs);
+ if (!owner) {
+ throw new Error(`找不到会话 ${selectedThread} 的 VS Code Codex owner。请确认该会话已在官方 Codex 面板打开。`);
+ }
+ this.threadId = selectedThread;
+ this.ownerClientId = owner;
+ // An owner can be Codex Desktop while the visible VS Code webview follows
+ // it, so ownerClientId is not always the route source. Bind the route
+ // source on the first observed old=false/new=true navigation pair instead.
+ if (!this.vscodeRouteActiveThreadId) this.vscodeRouteActiveThreadId = selectedThread;
+ this.state = "syncing";
+ if (!wasStarted) this.started = true;
+ // A normal startup announces the connection before waiting for the first
+ // stream snapshot. A waiting host already announced its IPC connection;
+ // emitting a second `connection.opened` would make the browser reset its
+ // connection indicator unnecessarily.
+ if (!wasStarted) this.emit({ type: "connection.opened", threadId: selectedThread, payload: { mode: "attach", ownerClientId: owner } });
+ // Do not expose a provisional thread as interactive while its first
+ // authoritative snapshot is still in flight.
+ this.waitingForSession = false;
+ try {
+ const waitForSnapshot = this.waitForSnapshot(selectedThread, this.options.followTimeoutMs, owner);
+ void waitForSnapshot.catch(() => undefined);
+ await this.client.followConversation(selectedThread, true, {
+ hostId: this.options.hostId,
+ targetClientIds: [owner],
+ });
+ await waitForSnapshot;
+ this.state = this.deriveSessionState();
+ this.waitingForSession = false;
+ this.clearWaitingDiscoveryTimer();
+ if (this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded();
+ this.options.logger?.info?.(`Attached to existing Codex conversation ${selectedThread}`);
+ } catch (error) {
+ // A failed follow must leave the adapter retryable and must not keep a
+ // stale thread/owner that could receive a later remote command.
+ this.clearSnapshotWaiter(error instanceof Error ? error : new Error(String(error)));
+ try { await this.client.followConversation(selectedThread, false, { hostId: this.options.hostId, targetClientIds: [owner] }); } catch { /* best effort */ }
+ const restoreWaiting = (fromWaiting || wasWaiting)
+ && !this.disposed
+ && lifecycleGeneration === this.sessionLifecycleGeneration;
+ // A transient attach attempt made from the waiting state must not tear
+ // down the IPC socket or relay. Restore the waiting projection and let
+ // the discovery loop try again after the official panel is ready.
+ this.started = restoreWaiting ? wasStarted : false;
+ this.state = restoreWaiting ? "waiting_for_host" : "disconnected";
+ this.waitingForSession = restoreWaiting;
+ this.threadId = null;
+ this.ownerClientId = null;
+ if (!restoreWaiting) {
+ this.vscodeRouteClientId = null;
+ this.vscodeRouteActiveThreadId = null;
+ this.vscodeRouteAwaitingSelection = false;
+ } else if (this.vscodeRouteGeneration === routeGeneration) {
+ this.vscodeRouteClientId = previousRouteClientId;
+ this.vscodeRouteActiveThreadId = previousRouteActiveThreadId;
+ this.vscodeRouteAwaitingSelection = previousRouteAwaitingSelection;
+ }
+ this.vscodeRouteCandidates.clear();
+ this.resetConversationProjection();
+ throw error;
+ }
+ }
+
+ /** Attach mode deliberately has no thread creation operation. */
+ async startThread(): Promise {
+ throw new Error("attach mode does not create a new thread; open an existing Codex conversation in VS Code");
+ }
+
+ /**
+ * Open a new conversation through the already-installed VS Code Codex
+ * extension. The callback is injected by the extension entrypoint; this
+ * follower never starts another Codex process.
+ */
+ async newSession(): Promise {
+ // Opening the official new-session panel is also the recovery action for
+ // `waiting_for_host`; it does not require an existing conversation owner.
+ this.ensureStarted();
+ if (!this.options.openNewSession) {
+ throw new Error("当前 VS Code Codex 扩展不支持从远程打开新会话");
+ }
+ return asJsonValue(await this.options.openNewSession());
+ }
+
+ async startTurn(params: JsonObject): Promise {
+ this.ensureInteractiveReady();
+ const input = extractInput(params);
+ const request = pickTurnRequest(params);
+ const result = await this.client.startTurn(this.threadId as string, input, {
+ request,
+ context: pickTurnContext(params),
+ clientUserMessageId: stringValue(params.clientUserMessageId),
+ ownerClientId: this.ownerClientId as string,
+ timeoutMs: this.options.followTimeoutMs,
+ });
+ const unwrapped = unwrapFollowerResult(result);
+ const nextTurn = extractTurnId(unwrapped);
+ if (nextTurn) {
+ this.turnId = nextTurn;
+ this.state = "active";
+ }
+ return asJsonValue(unwrapped);
+ }
+
+ async steerTurn(params: JsonObject): Promise {
+ this.ensureInteractiveReady();
+ const expected = stringValue(params.expectedTurnId) ?? this.turnId;
+ if (!expected) throw new Error("turn/steer requires an active turn");
+ const result = await this.client.steerTurn(this.threadId as string, extractInput(params), {
+ clientUserMessageId: stringValue(params.clientUserMessageId) ?? randomUUID(),
+ serviceTier: params.serviceTier === null || typeof params.serviceTier === "string" ? params.serviceTier : undefined,
+ attachments: Array.isArray(params.attachments) ? params.attachments : [],
+ additionalContext: isRecord(params.additionalContext) ? asJsonObject(params.additionalContext) : undefined,
+ restoreMessage: params.restoreMessage === null || params.restoreMessage !== undefined ? asJsonValue(params.restoreMessage) : undefined,
+ ownerClientId: this.ownerClientId as string,
+ timeoutMs: this.options.followTimeoutMs,
+ });
+ this.turnId = expected;
+ this.state = "active";
+ return asJsonValue(unwrapFollowerResult(result));
+ }
+
+ /** Persist model/reasoning settings on the already-open official thread. */
+ async updateThreadSettings(params: JsonObject): Promise {
+ this.ensureInteractiveReady();
+ const threadSettings = pickThreadSettingsUpdate(params);
+ const result = await this.client.updateThreadSettings(
+ this.threadId as string,
+ threadSettings,
+ {
+ ownerClientId: this.ownerClientId as string,
+ timeoutMs: this.options.followTimeoutMs,
+ },
+ );
+ return asJsonValue(unwrapFollowerResult(result));
+ }
+
+ /**
+ * Return the local VS Code conversations that this follower can attach to.
+ *
+ * The official extension obtains this list from its app-server client via
+ * `thread/list`. That request is intentionally not exposed by the private
+ * IPC router, so the bridge uses local rollout/index metadata only to find
+ * candidates. A candidate is returned after live owner discovery and, for a
+ * non-active conversation, a matching follower snapshot. Closed, stale, or
+ * desktop-owned rollouts are omitted instead of being shown as selectable
+ * history that attach mode cannot actually open.
+ */
+ async listSessions(params: JsonObject = {}): Promise {
+ // Session discovery is useful precisely while no conversation is attached
+ // (for example immediately after a fresh VS Code window opens).
+ this.ensureStarted();
+ const releaseSessionOperation = await this.acquireSessionOperation();
+ try {
+ return await this.listAttachableSessions(params);
+ } finally {
+ releaseSessionOperation();
+ }
+ }
+
+ private async listAttachableSessions(params: JsonObject): Promise {
+ const limitValue = numberValue(params.limit);
+ const limit = Math.max(1, Math.min(100, Number.isInteger(limitValue) ? limitValue as number : 50));
+ const codexHome = resolveCodexHome(this.options);
+ const [candidates, index] = await Promise.all([
+ recentVscodeThreadCandidates(path.join(codexHome, "sessions"), this.options.preferredCwds ?? []),
+ readSessionIndex(path.join(codexHome, "session_index.jsonl")),
+ ]);
+ const byId = new Map();
+ for (const candidate of candidates) {
+ const indexed = index.get(candidate.id);
+ byId.set(candidate.id, {
+ ...candidate,
+ ...(indexed?.title && !candidate.title ? { title: indexed.title } : {}),
+ ...(indexed?.updatedAtMs !== undefined && indexed.updatedAtMs > (candidate.updatedAtMs ?? 0)
+ ? { updatedAtMs: indexed.updatedAtMs } : {}),
+ ...(indexed?.cwd && !candidate.cwd ? { cwd: indexed.cwd } : {}),
+ });
+ }
+ // A configured/current thread can be valid even while its rollout has
+ // rotated away. Keep it in the picker so the active row is never lost.
+ if (this.threadId && !byId.has(this.threadId)) {
+ const title = stringValue(this.conversationState.title)
+ ?? stringValue(this.conversationState.name)
+ ?? stringValue(this.conversationState.threadTitle);
+ const cwd = stringValue(this.conversationState.cwd);
+ byId.set(this.threadId, {
+ id: this.threadId,
+ mtime: Date.now(),
+ updatedAtMs: Date.now(),
+ priority: 0,
+ ...(title ? { title } : {}),
+ ...(cwd ? { cwd } : {}),
+ });
+ }
+
+ const compareCandidates = (a: Candidate, b: Candidate) => (b.updatedAtMs ?? b.mtime) - (a.updatedAtMs ?? a.mtime)
+ || a.priority - b.priority;
+ const ordered = [...byId.values()]
+ .sort(compareCandidates)
+ .slice(0, limit);
+ // `limit` bounds expensive owner/snapshot probes, but a newer stale
+ // rollout must never consume the only slot and hide the active attachment.
+ const activeCandidate = this.threadId ? byId.get(this.threadId) : undefined;
+ if (activeCandidate && !ordered.some((candidate) => candidate.id === activeCandidate.id)) {
+ if (ordered.length >= limit) ordered[ordered.length - 1] = activeCandidate;
+ else ordered.push(activeCandidate);
+ ordered.sort(compareCandidates);
+ }
+ const sessions: SessionListEntry[] = [];
+ // Owner discovery is deliberately bounded/concurrent: stale rollout files
+ // are common, and one slow stale check must not block all other rows.
+ const ownerTimeout = Math.max(250, Math.min(this.options.ownerDiscoveryTimeoutMs, 750));
+ // A discovery response only proves that *some* client knows the thread.
+ // Require a short, targeted snapshot probe before advertising a non-active
+ // row as selectable; desktop-owned/stale threads can otherwise look live
+ // and leave the browser blank after a failed switch.
+ const snapshotProbeTimeout = Math.max(250, Math.min(this.options.followTimeoutMs, 750));
+ for (let offset = 0; offset < ordered.length; offset += 8) {
+ const batch = ordered.slice(offset, offset + 8);
+ const checked = await Promise.all(batch.map(async (candidate) => {
+ let owner: string | null = candidate.id === this.threadId ? this.ownerClientId : null;
+ if (!owner) {
+ try {
+ owner = await this.client.findThreadOwner(candidate.id, this.options.hostId, ownerTimeout);
+ } catch (error) {
+ this.options.logger?.debug?.(`Session owner discovery failed for ${candidate.id}`, error);
+ }
+ }
+ let available = false;
+ if (owner) {
+ available = candidate.id === this.threadId
+ || await this.probeSessionSnapshot(candidate.id, owner, snapshotProbeTimeout);
+ }
+ if (!available) return null;
+ const title = sanitizeSessionTitle(candidate.title) ?? `会话 ${candidate.id.slice(0, 8)}`;
+ return {
+ threadId: candidate.id,
+ title,
+ updatedAtMs: candidate.updatedAtMs ?? candidate.mtime,
+ ...(candidate.cwd ? { cwd: redactText(candidate.cwd) } : {}),
+ active: candidate.id === this.threadId,
+ available: true,
+ } satisfies SessionListEntry;
+ }));
+ for (const entry of checked) {
+ if (entry) sessions.push(entry);
+ }
+ // Route navigation has priority over populating more picker rows. The
+ // probes in this batch have already cleaned up their temporary follows;
+ // stop here so selectSession can acquire the shared operation lock
+ // instead of waiting behind dozens of stale rollout candidates.
+ if (this.sessionSwitching || this.pendingVscodeThreadId || this.activeVscodeSelection) break;
+ }
+ const result: SessionListResult = {
+ sessions,
+ activeThreadId: this.threadId,
+ };
+ return asJsonValue(result);
+ }
+
+ /** Attach to another already-open VS Code Codex conversation. */
+ async selectSession(params: JsonObject): Promise {
+ this.ensureStarted();
+ const origin = stringValue(params.origin) === "vscode" ? "vscode" : "web";
+ const expectedRouteGeneration = origin === "vscode"
+ ? numberValue(params.vscodeRouteGeneration)
+ : undefined;
+ const target = (stringValue(params.threadId) ?? stringValue(params.conversationId))?.trim();
+ if (!target) throw new Error("session/select requires threadId");
+ if (target === this.threadId) {
+ return asJsonValue({ threadId: target, previousThreadId: target, switched: false, available: true });
+ }
+ if (this.sessionSwitching) throw new Error("a session switch is already in progress");
+ if (this.turnId || this.pending.size) {
+ throw new Error("cannot switch sessions while a turn or approval is active");
+ }
+ const lifecycleGeneration = this.sessionLifecycleGeneration;
+ this.sessionSwitching = true;
+ const releaseSessionOperation = await this.acquireSessionOperation();
+ const previousThreadId = this.threadId;
+ const previousOwnerClientId = this.ownerClientId;
+ const previousState = this.state;
+ // Keep a copy of the last owner-validated old-session state before changing
+ // the active conversation. It is a deterministic fallback if the target
+ // cannot produce a snapshot (for example, an already-running desktop
+ // writer).
+ let previousConversationState: ConversationStreamState | undefined;
+ let owner: string | null = null;
+ let attachmentChanged = false;
+ try {
+ this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
+ owner = await this.client.findThreadOwner(target, this.options.hostId, this.options.ownerDiscoveryTimeoutMs);
+ if (!owner) throw new Error(`找不到会话 ${target} 的 VS Code Codex owner。请确认该会话已在官方 Codex 面板打开。`);
+ this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
+ // A local turn/approval may have appeared while owner discovery was in
+ // flight. Re-check immediately before detaching the old projection.
+ if (this.turnId || this.pending.size) {
+ throw new Error("cannot switch sessions while a turn or approval is active");
+ }
+ // Rollback must use the adapter's last owner-validated projection. The
+ // lower-level IPC cache sees every same-conversation snapshot before
+ // this adapter can reject a stale/unknown owner, so reading that cache
+ // here could resurrect content we deliberately ignored.
+ previousConversationState = previousThreadId && previousOwnerClientId
+ ? {
+ conversationId: previousThreadId,
+ hostId: this.options.hostId,
+ ownerClientId: previousOwnerClientId,
+ revision: this.revision ?? 0,
+ conversationState: cloneObject(this.conversationState),
+ }
+ : undefined;
+ this.emit({
+ type: "session.switching",
+ threadId: target,
+ payload: { previousThreadId: previousThreadId ?? null, targetThreadId: target },
+ });
+
+ // Keep the old follow alive until the new owner has supplied an
+ // authoritative snapshot. Events are filtered by `this.threadId`, so a
+ // stale old event cannot overwrite the target projection during attach.
+ this.threadId = target;
+ this.ownerClientId = owner;
+ this.state = "syncing";
+ this.waitingForSession = false;
+ this.resetConversationProjection();
+ attachmentChanged = true;
+ const waitForSnapshot = this.waitForSnapshot(target, this.options.followTimeoutMs, owner);
+ // If follow itself fails, the catch path rejects the waiter. Attach a
+ // handler immediately so that rejection can never become unhandled.
+ void waitForSnapshot.catch(() => undefined);
+ await this.client.followConversation(target, true, {
+ hostId: this.options.hostId,
+ targetClientIds: [owner],
+ });
+ await waitForSnapshot;
+ this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
+ // Revisions are scoped to an owner. A handoff can happen after the first
+ // snapshot, so confirm the owner again before committing/unfollowing A.
+ const confirmedOwner = await this.client.findThreadOwner(
+ target,
+ this.options.hostId,
+ this.options.ownerDiscoveryTimeoutMs,
+ );
+ if (confirmedOwner !== owner) {
+ throw new Error(`Codex conversation ${target} owner changed while switching`);
+ }
+ this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
+ if (previousThreadId && previousOwnerClientId) {
+ try {
+ await this.client.followConversation(previousThreadId, false, {
+ hostId: this.options.hostId,
+ targetClientIds: [previousOwnerClientId],
+ });
+ } catch (error) {
+ this.options.logger?.debug?.(`Unable to unfollow previous session ${previousThreadId}`, error);
+ }
+ }
+ this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
+ this.state = this.deriveSessionState();
+ this.waitingForSession = false;
+ this.clearWaitingDiscoveryTimer();
+ if (this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded();
+ this.emit({
+ type: "session.selected",
+ threadId: target,
+ payload: {
+ threadId: target,
+ activeThreadId: target,
+ previousThreadId: previousThreadId ?? null,
+ switched: true,
+ available: true,
+ },
+ });
+ return asJsonValue({ threadId: target, previousThreadId: previousThreadId ?? null, switched: true, available: true });
+ } catch (error) {
+ this.clearSnapshotWaiter(error instanceof Error ? error : new Error(String(error)));
+ if (!attachmentChanged) throw error;
+ const lifecycleCurrent = lifecycleGeneration === this.sessionLifecycleGeneration
+ && !this.disposed
+ && this.started;
+ // Best-effort cleanup of the target subscription, then restore the old
+ // attachment so a failed switch does not strand the bridge disconnected.
+ if (lifecycleCurrent) {
+ try {
+ await this.client.followConversation(target, false, {
+ hostId: this.options.hostId,
+ ...(owner ? { targetClientIds: [owner] } : {}),
+ });
+ } catch { /* best effort */ }
+ }
+ // dispose()/onClose owns the final disconnected state. An interrupted
+ // navigation must never publish rollback snapshots or reconnect after it.
+ if (!lifecycleCurrent) throw error;
+ this.threadId = previousThreadId;
+ this.ownerClientId = previousOwnerClientId;
+ this.state = previousState;
+ this.waitingForSession = !previousThreadId;
+ this.resetConversationProjection();
+ // Move Relay/Web back before re-publishing the old snapshot. Browser
+ // command errors arrive after adapter events, so relying on only the
+ // command envelope would make the restored old projection look like an
+ // out-of-route event and leave the early target snapshot mounted.
+ if (previousThreadId) {
+ this.emit({
+ type: "session.selected",
+ threadId: previousThreadId,
+ payload: {
+ threadId: previousThreadId,
+ activeThreadId: previousThreadId,
+ previousThreadId: target,
+ targetThreadId: target,
+ switched: false,
+ available: true,
+ failed: true,
+ origin,
+ },
+ });
+ }
+ const restoredFromCache = this.restoreCachedConversationProjection(
+ previousConversationState,
+ previousOwnerClientId,
+ );
+ if (restoredFromCache && previousThreadId) {
+ // Re-assert the old follow without waiting for another snapshot. The
+ // cached state is already authoritative and keeps the bridge usable
+ // even when the owner does not answer a duplicate follow request.
+ try {
+ await this.client.followConversation(previousThreadId, true, {
+ hostId: this.options.hostId,
+ targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined,
+ });
+ } catch (restoreError) {
+ this.options.logger?.debug?.("Unable to re-follow previous Codex session after switch failure", restoreError);
+ }
+ } else if (previousThreadId && previousOwnerClientId) {
+ try {
+ const restoreWaiter = this.waitForSnapshot(previousThreadId, this.options.followTimeoutMs, previousOwnerClientId);
+ void restoreWaiter.catch(() => undefined);
+ await this.client.followConversation(previousThreadId, true, {
+ hostId: this.options.hostId,
+ targetClientIds: [previousOwnerClientId],
+ });
+ await restoreWaiter;
+ this.state = this.deriveSessionState();
+ } catch (restoreError) {
+ this.options.logger?.warn?.("Unable to restore previous Codex session after switch failure", restoreError);
+ }
+ }
+ if (!previousThreadId && this.started && !this.disposed) this.scheduleWaitingDiscovery();
+ throw error;
+ } finally {
+ releaseSessionOperation();
+ this.sessionSwitching = false;
+ }
+ }
+
+ /** Acquire a FIFO lock shared by session probes and attachment changes. */
+ private async acquireSessionOperation(): Promise<() => void> {
+ const previous = this.sessionOperationTail;
+ let release!: () => void;
+ this.sessionOperationTail = new Promise((resolve) => { release = resolve; });
+ await previous;
+ return release;
+ }
+
+ private assertSessionSelectionCurrent(
+ target: string,
+ origin: "web" | "vscode",
+ lifecycleGeneration: number,
+ expectedRouteGeneration?: number,
+ ): void {
+ if (lifecycleGeneration !== this.sessionLifecycleGeneration || this.disposed || !this.started) {
+ throw new Error("session selection was cancelled because the IPC session closed");
+ }
+ if (origin === "vscode"
+ && (expectedRouteGeneration === undefined
+ || expectedRouteGeneration !== this.vscodeRouteGeneration
+ || this.vscodeRouteActiveThreadId !== target)) {
+ throw new Error("session selection was superseded by a newer VS Code route");
+ }
+ }
+
+ async interruptTurn(params: JsonObject = {}): Promise {
+ this.ensureInteractiveReady();
+ const expected = stringValue(params.turnId) ?? stringValue(params.expectedTurnId) ?? this.turnId;
+ if (!expected) throw new Error("turn/interrupt requires an active turn");
+ const mode = stringValue(params.mode) ?? "user-stop";
+ const result = await this.client.interruptTurn(this.threadId as string, {
+ mode,
+ expectedTurnId: expected,
+ ownerClientId: this.ownerClientId as string,
+ timeoutMs: this.options.followTimeoutMs,
+ });
+ this.turnId = null;
+ this.state = "idle";
+ const elapsed = this.status.elapsedMs ?? this.status.durationMs ?? null;
+ this.status = {
+ ...this.status,
+ activity: "interrupted",
+ turnStatus: "interrupted",
+ activeFlags: [],
+ durationMs: elapsed,
+ workedDurationMs: this.status.workedDurationMs ?? elapsed,
+ elapsedMs: elapsed,
+ };
+ this.emit({ type: "task.cancelled", threadId: this.threadId ?? undefined, turnId: expected, payload: { mode } });
+ return asJsonValue(unwrapFollowerResult(result));
+ }
+
+ async sendInput(text: string, params: JsonObject = {}): Promise {
+ const body = { ...params, text };
+ return this.turnId ? this.steerTurn(body) : this.startTurn(body);
+ }
+
+ async cancel(taskId?: string, params: JsonObject = {}): Promise {
+ return this.interruptTurn({ ...params, ...(taskId ? { turnId: taskId } : {}) });
+ }
+
+ async respondApproval(
+ requestId: JsonRpcId,
+ decision: "allow" | "deny" | "cancel",
+ reason?: string,
+ response?: JsonValue,
+ ): Promise {
+ return this.resolvePendingResponse(requestId, decision, reason, response, false);
+ }
+
+ private async resolvePendingResponse(
+ requestId: JsonRpcId,
+ decision: "allow" | "deny" | "cancel",
+ reason: string | undefined,
+ response: JsonValue | undefined,
+ allowDuringSessionSwitch: boolean,
+ ): Promise {
+ this.ensureAttached();
+ if (this.sessionSwitching && !allowDuringSessionSwitch) {
+ throw new Error("Codex session switch is still in progress");
+ }
+ const key = jsonRpcIdKey(requestId);
+ const pending = this.pending.get(key);
+ if (!pending) throw new Error(`unknown or already resolved request: ${key}`);
+ const wire = this.toWireResponse(pending, decision, reason, response);
+ // Remove before awaiting the owner so a repeated browser click cannot send
+ // the same approval twice. If the IPC request fails, restore it for retry.
+ this.pending.delete(key);
+ this.clearPendingTimer(key);
+ this.optimisticallyResolved.add(key);
+ let result: JsonValue | undefined;
+ try {
+ result = await this.sendPendingResponse(pending, wire);
+ } catch (error) {
+ this.optimisticallyResolved.delete(key);
+ this.pending.set(key, pending);
+ this.schedulePendingExpiry(key, pending);
+ throw error;
+ }
+ this.emit({
+ type: pending.approval ? "approval.resolved" : "input.resolved",
+ threadId: pending.threadId ?? this.threadId ?? undefined,
+ turnId: pending.turnId,
+ requestId,
+ payload: { requestId: asJsonValue(requestId), method: pending.method, decision },
+ });
+ return asJsonValue(result);
+ }
+
+ async denyPending(reason = "relay disconnected"): Promise {
+ const entries = [...this.pending.values()];
+ await Promise.all(entries.map(async (entry) => {
+ try {
+ await this.resolvePendingResponse(entry.requestId, "deny", reason, undefined, true);
+ } catch { /* fail closed when owner is gone */ }
+ }));
+ }
+
+ private async sendPendingResponse(entry: RequestEntry, wire: JsonValue): Promise {
+ const conversationId = this.threadId as string;
+ const options = { ownerClientId: this.ownerClientId as string, timeoutMs: this.options.followTimeoutMs };
+ if (entry.method === "item/commandExecution/requestApproval" || entry.method === "execCommandApproval") {
+ return this.client.respondCommandApproval(conversationId, entry.requestId, wire, options);
+ }
+ if (entry.method === "item/fileChange/requestApproval" || entry.method === "applyPatchApproval") {
+ return this.client.respondFileApproval(conversationId, entry.requestId, wire, options);
+ }
+ if (entry.method === "item/permissions/requestApproval") {
+ return this.client.respondPermissionsApproval(conversationId, entry.requestId, wire, options);
+ }
+ if (entry.method === "item/tool/requestUserInput") {
+ return this.client.respondUserInput(conversationId, entry.requestId, wire, options);
+ }
+ if (entry.method === "mcpServer/elicitation/request") {
+ return this.client.respondMcpElicitation(conversationId, entry.requestId, wire, options);
+ }
+ throw new Error(`unsupported follower request method: ${entry.method}`);
+ }
+
+ private schedulePendingExpiry(key: string, entry: RequestEntry): void {
+ if (!entry.expiresAt || this.options.approvalTimeoutMs <= 0) {
+ // A later snapshot can omit an expiry that was present in an earlier
+ // request record. Do not leave the old timer alive in that case.
+ this.clearPendingTimer(key);
+ return;
+ }
+ const existing = this.pendingExpiryAt.get(key);
+ if (existing === entry.expiresAt && this.pendingTimers.has(key)) return;
+ this.clearPendingTimer(key);
+ const delay = Math.max(0, entry.expiresAt - Date.now());
+ this.pendingExpiryAt.set(key, entry.expiresAt);
+ this.pendingTimers.set(key, setTimeout(() => {
+ this.pendingTimers.delete(key);
+ this.pendingExpiryAt.delete(key);
+ void this.expirePending(key);
+ }, delay));
+ }
+
+ private clearPendingTimer(key: string): void {
+ const timer = this.pendingTimers.get(key);
+ if (timer) clearTimeout(timer);
+ this.pendingTimers.delete(key);
+ this.pendingExpiryAt.delete(key);
+ }
+
+ private async expirePending(key: string): Promise {
+ const entry = this.pending.get(key);
+ if (!entry || this.disposed || !this.started) return;
+ this.pending.delete(key);
+ this.optimisticallyResolved.add(key);
+ try {
+ const wire = this.toWireResponse(entry, "deny", "approval expired");
+ await this.sendPendingResponse(entry, wire);
+ } catch (error) {
+ this.options.logger?.debug?.(`Unable to send expiry response for ${key}`, error);
+ }
+ this.emit({
+ type: entry.approval ? "approval.expired" : "input.expired",
+ threadId: entry.threadId ?? this.threadId ?? undefined,
+ turnId: entry.turnId,
+ requestId: entry.requestId,
+ payload: { requestId: asJsonValue(entry.requestId), method: entry.method, reason: "approval expired" },
+ });
+ }
+
+ async snapshot(): Promise {
+ const requests = [...this.pending.values()];
+ return {
+ threadId: this.threadId,
+ turnId: this.turnId,
+ state: this.state,
+ status: { ...this.status, activeFlags: [...this.status.activeFlags] },
+ activity: this.status.activity,
+ turnStatus: this.status.turnStatus,
+ activeFlags: [...this.status.activeFlags],
+ startedAtMs: this.status.startedAtMs ?? null,
+ durationMs: this.status.durationMs ?? null,
+ workedDurationMs: this.status.workedDurationMs ?? null,
+ elapsedMs: this.status.elapsedMs ?? null,
+ pendingApprovals: requests.map((entry) => entry.approval).filter((entry): entry is PendingApproval => Boolean(entry)),
+ pendingRequests: requests.map((entry) => ({
+ requestId: entry.requestId,
+ method: entry.method,
+ params: redactJson(entry.params),
+ ...(entry.approval?.commandHash ? { commandHash: entry.approval.commandHash } : {}),
+ ...(entry.approval?.risk ? { risk: entry.approval.risk } : {}),
+ ...(entry.approval?.summary ? { summary: entry.approval.summary } : {}),
+ createdAt: entry.createdAt,
+ ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
+ })),
+ outputTail: this.outputTail,
+ messages: asJsonValue(this.outputMessages) as JsonValue[],
+ subagents: this.subagents.map((subagent) => ({ ...subagent })),
+ metadata: {
+ adapter: "codex-ipc-follower",
+ mode: "attach",
+ privateProtocol: true,
+ socketPath: this.client.socketPath,
+ waitingForSession: this.waitingForSession,
+ attachReady: Boolean(this.threadId && this.ownerClientId && !this.waitingForSession && this.state !== "syncing"),
+ ...(this.ownerClientId ? { ownerClientId: this.ownerClientId } : {}),
+ ...(this.revision !== null ? { revision: this.revision } : {}),
+ ...(typeof this.conversationState.cwd === "string" ? { cwd: this.conversationState.cwd } : {}),
+ ...(typeof this.conversationState.title === "string" ? { title: this.conversationState.title } : {}),
+ ...(typeof this.conversationState.source === "string" ? { source: this.conversationState.source } : {}),
+ ...projectSessionMetadata(this.conversationState),
+ activity: this.status.activity,
+ turnStatus: this.status.turnStatus,
+ activeFlags: asJsonValue(this.status.activeFlags),
+ startedAtMs: this.status.startedAtMs ?? null,
+ durationMs: this.status.durationMs ?? null,
+ workedDurationMs: this.status.workedDurationMs ?? null,
+ elapsedMs: this.status.elapsedMs ?? null,
+ firstTurnWorkItemStartedAtMs: this.status.firstTurnWorkItemStartedAtMs ?? null,
+ finalAssistantStartedAtMs: this.status.finalAssistantStartedAtMs ?? null,
+ historyComplete: !hasIncompleteHistory(this.conversationState),
+ },
+ };
+ }
+
+ onEvent(listener: (event: AgentEvent) => void): Disposable {
+ this.listeners.add(listener);
+ return { dispose: () => this.listeners.delete(listener) };
+ }
+
+ async dispose(): Promise {
+ if (this.disposed) return;
+ this.sessionLifecycleGeneration += 1;
+ this.clearWaitingDiscoveryTimer();
+ this.waitingForSession = false;
+ this.clearSnapshotWaiter(new Error("Codex IPC follower is stopping"));
+ this.clearRevisionWaiter(new Error("Codex IPC follower is stopping"));
+ // While the private IPC socket is still writable, close every outstanding
+ // owner request explicitly so stopping the bridge cannot strand an approval
+ // dialog in the official Codex UI.
+ if (this.started && this.pending.size) await this.denyPending("bridge stopped");
+ this.disposed = true;
+ this.started = false;
+ this.state = "disconnected";
+ this.clearPendingVscodeFollow();
+ this.vscodeRouteActiveThreadId = null;
+ this.vscodeRouteAwaitingSelection = false;
+ this.vscodeRouteClientId = null;
+ this.vscodeRouteCandidates.clear();
+ this.activeVscodeSelection = undefined;
+ this.resetHistoryLoading();
+ this.clearSnapshotWaiter(new Error("Codex IPC follower disposed"));
+ this.clearRevisionWaiter(new Error("Codex IPC follower disposed"));
+ for (const timer of this.pendingTimers.values()) clearTimeout(timer);
+ this.pendingTimers.clear();
+ this.pendingExpiryAt.clear();
+ if (this.threadId) {
+ try {
+ await this.client.followConversation(this.threadId, false, {
+ hostId: this.options.hostId,
+ targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined,
+ });
+ } catch { /* socket may already be closed */ }
+ }
+ for (const entry of this.pending.values()) {
+ if (entry.approval) this.emit({ type: "approval.expired", threadId: entry.threadId, turnId: entry.turnId, requestId: entry.requestId, payload: { requestId: asJsonValue(entry.requestId), reason: "bridge stopped" } });
+ else this.emit({ type: "input.expired", threadId: entry.threadId, turnId: entry.turnId, requestId: entry.requestId, payload: { requestId: asJsonValue(entry.requestId), reason: "bridge stopped" } });
+ }
+ this.pending.clear();
+ this.optimisticallyResolved.clear();
+ for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
+ if (this.options.disposeClient) await this.client.dispose();
+ }
+
+ private async discoverThreadId(excluded = new Set(), maxCandidates = 64): Promise {
+ if (!this.options.autoDiscoverThread) return undefined;
+ const codexHome = resolveCodexHome(this.options);
+ const candidates = await recentVscodeThreadCandidates(path.join(codexHome, "sessions"), this.options.preferredCwds ?? []);
+ if (!candidates.length) return undefined;
+ // Owner discovery is the authority: a rollout file can remain on disk
+ // after its VS Code owner is gone. An older conversation can still be the
+ // one currently open in the official panel, so inspect enough candidates
+ // to cover its bounded recent-chat view. Eight-way batches with a short
+ // timeout keep the worst-case startup budget no larger than the former
+ // 12-candidate, four-way scan.
+ const limited = candidates.filter((candidate) => !excluded.has(candidate.id)).slice(0, Math.max(1, maxCandidates));
+ const ownerTimeout = Math.max(250, Math.min(this.options.ownerDiscoveryTimeoutMs, 750));
+ for (let offset = 0; offset < limited.length; offset += 8) {
+ const batch = limited.slice(offset, offset + 8);
+ const checks = await Promise.all(batch.map(async (candidate) => {
+ try {
+ const owner = await this.client.findThreadOwner(candidate.id, this.options.hostId, ownerTimeout);
+ return owner ? candidate : undefined;
+ } catch (error) {
+ this.options.logger?.debug?.(`Thread owner discovery failed for ${candidate.id}`, error);
+ return undefined;
+ }
+ }));
+ const selected = checks.find((candidate): candidate is Candidate => Boolean(candidate));
+ if (selected) {
+ this.options.logger?.info?.(`Auto-discovered VS Code Codex conversation ${selected.id}`);
+ return selected.id;
+ }
+ }
+ return undefined;
+ }
+
+ /**
+ * Keep the relay/IPC connection usable while the official panel has no
+ * selected conversation yet. The first route broadcast is handled as a
+ * fast path; this bounded poll covers sessions opened by older extension
+ * builds that do not emit the route notification.
+ */
+ private enterWaitingForSession(): void {
+ if (this.disposed) return;
+ const wasStarted = this.started;
+ const wasWaiting = this.waitingForSession;
+ this.started = true;
+ this.waitingForSession = true;
+ this.state = "waiting_for_host";
+ this.threadId = null;
+ this.ownerClientId = null;
+ this.vscodeRouteActiveThreadId = null;
+ this.vscodeRouteClientId = null;
+ this.vscodeRouteAwaitingSelection = false;
+ this.vscodeRouteCandidates.clear();
+ this.resetConversationProjection();
+ // RelayHost normally synthesizes this event after adapter.start(). Emit it
+ // here as well so the adapter can be used directly and so a transition
+ // back to waiting clears any provisional target in existing browsers.
+ if (!wasWaiting) {
+ this.emit({ type: "connection.opened", payload: { mode: "attach", waitingForSession: true } });
+ if (wasStarted) void this.emitSnapshot();
+ }
+ this.scheduleWaitingDiscovery();
+ }
+
+ private scheduleWaitingDiscovery(delayMs = WAITING_SESSION_DISCOVERY_DELAY_MS): void {
+ if (this.disposed || !this.started || !this.waitingForSession || this.waitingDiscoveryTimer) return;
+ this.waitingDiscoveryTimer = setTimeout(() => {
+ this.waitingDiscoveryTimer = undefined;
+ void this.runWaitingDiscovery();
+ }, Math.max(0, delayMs));
+ }
+
+ private clearWaitingDiscoveryTimer(): void {
+ if (this.waitingDiscoveryTimer) clearTimeout(this.waitingDiscoveryTimer);
+ this.waitingDiscoveryTimer = undefined;
+ }
+
+ private async runWaitingDiscovery(): Promise {
+ if (this.disposed || !this.started || !this.waitingForSession || this.waitingDiscoveryInFlight) return;
+ const generation = this.sessionLifecycleGeneration;
+ const routeGeneration = this.vscodeRouteGeneration;
+ const officialRouteHasPriority = () => this.options.followVscodeSession
+ && (this.vscodeRouteGeneration !== routeGeneration
+ || Boolean(this.vscodeRouteClientId && this.vscodeRouteActiveThreadId));
+ const task = (async () => {
+ // Polling is only a fallback for panels/builds that do not broadcast their
+ // current route. Once the official panel supplies a route, never let a
+ // slower filesystem/owner lookup replace it with an older recent thread.
+ if (officialRouteHasPriority()) return;
+ const configured = this.options.threadId?.trim();
+ if (configured && await this.tryAttachWaitingThread(configured)) return;
+ if (officialRouteHasPriority()) return;
+ if (!this.options.autoDiscoverThread) return;
+ const selected = await this.discoverThreadId(
+ configured ? new Set([configured]) : new Set(),
+ WAITING_SESSION_DISCOVERY_MAX_CANDIDATES,
+ );
+ if (selected && !officialRouteHasPriority()) await this.tryAttachWaitingThread(selected);
+ })();
+ this.waitingDiscoveryInFlight = task;
+ try {
+ await task;
+ } catch (error) {
+ if (this.started && !this.disposed) {
+ this.options.logger?.debug?.("Waiting for a VS Code Codex session", error);
+ }
+ } finally {
+ if (this.waitingDiscoveryInFlight === task) this.waitingDiscoveryInFlight = undefined;
+ // A socket close/dispose may have happened while discovery was in
+ // flight. The generation check prevents a stale completion from
+ // scheduling a new timer on a dead adapter.
+ if (generation === this.sessionLifecycleGeneration && this.started && this.waitingForSession && !this.disposed) {
+ this.scheduleWaitingDiscovery();
+ }
+ }
+ }
+
+ private async tryAttachWaitingThread(target: string): Promise {
+ const normalizedTarget = target.trim();
+ if (!normalizedTarget || this.disposed || !this.started) return false;
+ if (this.waitingAttachPromise) {
+ if (this.waitingAttachTarget !== normalizedTarget) this.queuedWaitingAttachTarget = normalizedTarget;
+ return this.waitingAttachTarget === normalizedTarget ? this.waitingAttachPromise : false;
+ }
+ if (!this.waitingForSession) return false;
+ this.waitingAttachTarget = normalizedTarget;
+ const task = (async () => {
+ const release = await this.acquireSessionOperation();
+ this.sessionSwitching = true;
+ try {
+ if (this.disposed || !this.started || !this.waitingForSession) return false;
+ await this.attachThread(normalizedTarget, { fromWaiting: true });
+ return true;
+ } catch (error) {
+ if (this.started && !this.disposed) {
+ this.options.logger?.debug?.(`Unable to attach waiting VS Code session ${normalizedTarget}`, error);
+ // attachThread restores the waiting projection for a failed
+ // fromWaiting attempt. Keep the retry timer alive for the next poll.
+ this.scheduleWaitingDiscovery();
+ }
+ return false;
+ } finally {
+ this.sessionSwitching = false;
+ release();
+ }
+ })();
+ this.waitingAttachPromise = task;
+ let attached = false;
+ try {
+ attached = await task;
+ return attached;
+ } finally {
+ if (this.waitingAttachPromise === task) this.waitingAttachPromise = undefined;
+ this.waitingAttachTarget = null;
+ const queued = this.queuedWaitingAttachTarget;
+ this.queuedWaitingAttachTarget = null;
+ if (queued && !this.disposed && this.started) {
+ if (this.waitingForSession) {
+ void this.tryAttachWaitingThread(queued);
+ } else if (queued !== this.threadId) {
+ // The official panel can move A -> B while A's first snapshot is in
+ // flight. Preserve the latest route and reuse the normal deferred
+ // switching path so an active turn on A is never detached early.
+ this.pendingVscodeThreadId = queued;
+ this.pendingVscodeFollowAttempts = 0;
+ this.pendingVscodeFollowGeneration = this.vscodeRouteGeneration;
+ this.schedulePendingVscodeFollow(this.options.vscodeSessionFollowDebounceMs);
+ }
+ }
+ }
+ }
+
+ /**
+ * Verify that a discovered owner can actually stream this conversation.
+ * Owner discovery may return a desktop client (or a stale handoff) that
+ * knows the id but cannot provide the follower snapshot needed for attach.
+ * The temporary follow is isolated to a direct stream listener and is always
+ * removed before returning, so probing never changes the active projection.
+ */
+ private async probeSessionSnapshot(conversationId: string, ownerClientId: string, timeoutMs: number): Promise {
+ let timer: NodeJS.Timeout | undefined;
+ let resolveSnapshot!: (available: boolean) => void;
+ const snapshot = new Promise((resolve) => {
+ resolveSnapshot = resolve;
+ timer = setTimeout(() => resolve(false), timeoutMs);
+ });
+ const subscription = this.client.onStreamEvent((event) => {
+ if (event.kind === "snapshot"
+ && event.conversationId === conversationId
+ && event.ownerClientId === ownerClientId) {
+ resolveSnapshot(true);
+ }
+ });
+ try {
+ await this.client.followConversation(conversationId, true, {
+ hostId: this.options.hostId,
+ targetClientIds: [ownerClientId],
+ });
+ return await snapshot;
+ } catch (error) {
+ this.options.logger?.debug?.(`Session snapshot probe failed for ${conversationId}`, error);
+ return false;
+ } finally {
+ if (timer) clearTimeout(timer);
+ subscription.dispose();
+ // A user may select this row while the probe is waiting. In that case
+ // the temporary follow has become the active attachment; do not tear it
+ // down from the list request's cleanup path.
+ if (!(this.threadId === conversationId && this.ownerClientId === ownerClientId)) {
+ try {
+ await this.client.followConversation(conversationId, false, {
+ hostId: this.options.hostId,
+ targetClientIds: [ownerClientId],
+ });
+ } catch (error) {
+ this.options.logger?.debug?.(`Unable to clean up session snapshot probe ${conversationId}`, error);
+ }
+ }
+ }
+ }
+
+ /**
+ * Observe the official panel's route without reading its DOM. The official
+ * webview broadcasts old=false followed by new=true from one stable IPC
+ * client. Requiring that pair avoids treating reconnect status replays or a
+ * different Codex window's isolated true broadcast as a user navigation.
+ */
+ private handleBroadcast(frame: IpcBroadcast): void {
+ if (!this.options.followVscodeSession || !this.started || this.disposed) return;
+ if (frame.method === "client-status-changed") {
+ this.handleRouteClientStatus(frame);
+ return;
+ }
+ if (frame.method !== "thread-stream-following-changed") return;
+ if (this.options.strictVersions !== false
+ && frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"]) return;
+ // Status replies sent to a newly connected follower describe retained
+ // subscriptions, not a fresh route change in the VS Code panel.
+ if (frame.targetClientIds?.length) return;
+ if (!isRecord(frame.params)) return;
+ const conversationId = stringValue(frame.params.conversationId)?.trim();
+ const hostId = stringValue(frame.params.hostId) ?? "local";
+ const sourceClientId = frame.sourceClientId?.trim();
+ if (!conversationId || hostId !== this.options.hostId || !sourceClientId) return;
+ if (sourceClientId === this.client.getClientId()) return;
+
+ // There is no trusted old route while the first conversation is missing.
+ // Treat an untargeted `following:true` as a candidate hint, then verify it
+ // through owner discovery and an authoritative snapshot before attaching.
+ if (this.waitingForSession || this.waitingAttachPromise) {
+ if (frame.params.following !== true) return;
+ if (this.vscodeRouteClientId && sourceClientId !== this.vscodeRouteClientId) return;
+ this.vscodeRouteClientId = sourceClientId;
+ if (this.vscodeRouteActiveThreadId !== conversationId) {
+ this.vscodeRouteActiveThreadId = conversationId;
+ this.vscodeRouteGeneration += 1;
+ }
+ void this.tryAttachWaitingThread(conversationId);
+ return;
+ }
+
+ // Bind only after one source completes old=false -> new=true. A different
+ // remote follower can emit an isolated false while disposing, and that
+ // must not permanently steal the trusted route source.
+ if (!this.vscodeRouteClientId) {
+ if (frame.params.following === false) {
+ if (conversationId === this.vscodeRouteActiveThreadId) {
+ this.vscodeRouteCandidates.set(sourceClientId, conversationId);
+ }
+ return;
+ }
+ if (frame.params.following !== true) return;
+ if (this.vscodeRouteCandidates.get(sourceClientId) !== this.vscodeRouteActiveThreadId) return;
+ this.vscodeRouteClientId = sourceClientId;
+ this.vscodeRouteCandidates.clear();
+ this.vscodeRouteActiveThreadId = null;
+ this.vscodeRouteAwaitingSelection = true;
+ this.vscodeRouteGeneration += 1;
+ }
+ if (sourceClientId !== this.vscodeRouteClientId) return;
+
+ if (frame.params.following === false) {
+ if (conversationId !== this.vscodeRouteActiveThreadId) return;
+ this.vscodeRouteGeneration += 1;
+ this.vscodeRouteActiveThreadId = null;
+ this.vscodeRouteAwaitingSelection = true;
+ if (this.activeVscodeSelection?.target === conversationId) {
+ this.clearSnapshotWaiter(new Error(`VS Code moved away from conversation ${conversationId}`));
+ }
+ // A -> B -> C can happen faster than B's snapshot. Cancel the queued B
+ // as soon as the official route reports that B is no longer active.
+ if (this.pendingVscodeThreadId === conversationId) this.clearPendingVscodeFollow();
+ return;
+ }
+ if (frame.params.following !== true) return;
+
+ if (conversationId === this.vscodeRouteActiveThreadId) return;
+ if (!this.vscodeRouteAwaitingSelection) {
+ // Initial/reconnect following status. It may confirm the current route,
+ // but an isolated true is intentionally never treated as navigation.
+ if (conversationId === this.threadId) this.vscodeRouteActiveThreadId = conversationId;
+ return;
+ }
+ this.vscodeRouteAwaitingSelection = false;
+ this.vscodeRouteActiveThreadId = conversationId;
+ this.vscodeRouteGeneration += 1;
+ if (conversationId === this.threadId) {
+ this.clearPendingVscodeFollow();
+ return;
+ }
+ this.pendingVscodeThreadId = conversationId;
+ this.pendingVscodeFollowAttempts = 0;
+ this.pendingVscodeFollowGeneration = this.vscodeRouteGeneration;
+ this.schedulePendingVscodeFollow(this.options.vscodeSessionFollowDebounceMs);
+ }
+
+ /**
+ * The official webview gets a new IPC client id after its socket reconnects.
+ * Drop only the trusted route-source binding when that client disconnects;
+ * the next same-source false -> true pair can then establish the replacement.
+ */
+ private handleRouteClientStatus(frame: IpcBroadcast): void {
+ if (this.options.strictVersions !== false && frame.version !== 0) return;
+ if (!isRecord(frame.params)) return;
+ const clientId = (stringValue(frame.params.clientId) ?? frame.sourceClientId)?.trim();
+ const status = stringValue(frame.params.status)?.trim().toLowerCase();
+ if (!clientId || status !== "disconnected") return;
+ this.vscodeRouteCandidates.delete(clientId);
+ if (clientId !== this.vscodeRouteClientId) return;
+
+ this.options.logger?.debug?.(`VS Code Codex route client ${clientId} disconnected; awaiting a replacement source`);
+ this.vscodeRouteClientId = null;
+ this.vscodeRouteCandidates.clear();
+ this.vscodeRouteAwaitingSelection = false;
+ // Keep the route identity, rather than forcing it back to the currently
+ // committed relay thread. A disconnect may occur between B's true signal
+ // and B's snapshot; the replacement panel will later leave B with false.
+ this.vscodeRouteActiveThreadId = this.activeVscodeSelection?.target
+ ?? this.vscodeRouteActiveThreadId
+ ?? this.threadId;
+ this.vscodeRouteGeneration += 1;
+ this.clearPendingVscodeFollow();
+ if (this.activeVscodeSelection) {
+ this.clearSnapshotWaiter(new Error("VS Code Codex route client disconnected during session selection"));
+ }
+ }
+
+ private schedulePendingVscodeFollow(delayMs: number): void {
+ if (!this.pendingVscodeThreadId || this.disposed || !this.started) return;
+ if (this.vscodeSessionFollowTimer) clearTimeout(this.vscodeSessionFollowTimer);
+ this.vscodeSessionFollowTimer = setTimeout(() => {
+ this.vscodeSessionFollowTimer = undefined;
+ void this.applyPendingVscodeFollow();
+ }, Math.max(0, delayMs));
+ }
+
+ private async applyPendingVscodeFollow(): Promise {
+ const target = this.pendingVscodeThreadId;
+ const routeGeneration = this.pendingVscodeFollowGeneration;
+ if (!target || this.disposed || !this.started) return;
+ if (this.vscodeRouteActiveThreadId !== target || routeGeneration !== this.vscodeRouteGeneration) {
+ if (this.pendingVscodeThreadId === target) this.clearPendingVscodeFollow();
+ return;
+ }
+ if (target === this.threadId) {
+ this.clearPendingVscodeFollow();
+ return;
+ }
+ // Never detach a turn or approval that is still owned by the old thread.
+ // Keep only the latest official target and retry once that state is idle.
+ if (this.sessionSwitching || this.turnId || this.pending.size) {
+ this.schedulePendingVscodeFollow(VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS);
+ return;
+ }
+
+ this.pendingVscodeThreadId = null;
+ this.pendingVscodeFollowGeneration = 0;
+ this.activeVscodeSelection = { target, generation: routeGeneration };
+ try {
+ await this.selectSession({
+ threadId: target,
+ origin: "vscode",
+ vscodeRouteGeneration: routeGeneration,
+ });
+ this.pendingVscodeFollowAttempts = 0;
+ this.options.logger?.info?.(`Followed VS Code Codex panel to conversation ${target}`);
+ } catch (error) {
+ this.options.logger?.debug?.(`Unable to follow VS Code Codex panel to ${target}`, error);
+ if (!this.disposed
+ && this.started
+ && !this.pendingVscodeThreadId
+ && this.vscodeRouteActiveThreadId === target
+ && this.vscodeRouteGeneration === routeGeneration
+ && this.pendingVscodeFollowAttempts < VSCODE_SESSION_FOLLOW_MAX_ATTEMPTS) {
+ this.pendingVscodeFollowAttempts += 1;
+ this.pendingVscodeThreadId = target;
+ this.pendingVscodeFollowGeneration = routeGeneration;
+ }
+ } finally {
+ if (this.activeVscodeSelection?.target === target
+ && this.activeVscodeSelection.generation === routeGeneration) {
+ this.activeVscodeSelection = undefined;
+ }
+ if (this.pendingVscodeThreadId) {
+ this.schedulePendingVscodeFollow(VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS);
+ }
+ }
+ }
+
+ private clearPendingVscodeFollow(): void {
+ if (this.vscodeSessionFollowTimer) clearTimeout(this.vscodeSessionFollowTimer);
+ this.vscodeSessionFollowTimer = undefined;
+ this.pendingVscodeThreadId = null;
+ this.pendingVscodeFollowAttempts = 0;
+ this.pendingVscodeFollowGeneration = 0;
+ }
+
+ private handleStreamEvent(event: ConversationStreamEvent): void {
+ if (!this.threadId || event.conversationId !== this.threadId) return;
+ // Following is targeted at the owner discovered during attach. The IPC
+ // router normally filters these broadcasts, but a handoff/old socket can
+ // still surface another client's event; applying it would overwrite the
+ // active conversation and could satisfy a revision waiter incorrectly.
+ if (this.ownerClientId && event.ownerClientId && event.ownerClientId !== this.ownerClientId) {
+ this.options.logger?.debug?.(`Ignoring stream event from unexpected Codex owner ${event.ownerClientId}`);
+ return;
+ }
+ if (event.kind === "desync") {
+ this.options.logger?.warn?.(`Codex IPC stream desynchronized at revision ${event.receivedBaseRevision}; requesting snapshot`);
+ this.client.followConversation(this.threadId, true, { hostId: this.options.hostId, targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined }).catch((error) => this.options.logger?.warn?.("Unable to recover IPC snapshot", error));
+ return;
+ }
+ this.ownerClientId = event.ownerClientId || this.ownerClientId;
+ this.revision = event.revision;
+ if (this.revisionWaiter
+ && this.revisionWaiter.threadId === event.conversationId
+ && this.revisionWaiter.ownerClientId === event.ownerClientId
+ && event.revision >= this.revisionWaiter.revision) {
+ this.clearRevisionWaiter();
+ }
+ this.conversationState = cloneObject(event.conversationState);
+ this.processConversationState(event.kind === "snapshot");
+ if (event.kind === "snapshot" && this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded();
+ if (event.kind === "snapshot"
+ && this.snapshotWaiter?.threadId === event.conversationId
+ && (!this.snapshotWaiter.ownerClientId || this.snapshotWaiter.ownerClientId === event.ownerClientId)) {
+ this.clearSnapshotWaiter();
+ }
+ }
+
+ private processConversationState(initial: boolean): void {
+ if (initial) this.snapshotSeen = true;
+ const previousTurnId = this.turnId;
+ const previousState = this.state;
+ const previousStatus = this.status;
+ const nextTurn = deriveTurn(this.conversationState);
+ // Request records and runtime flags are part of the same conversation
+ // snapshot. Derive status after extracting them so an approval/input wait
+ // is visible immediately with the corresponding state patch.
+ const nextRequests = extractRequests(this.conversationState, this.options.approvalTimeoutMs);
+ this.status = deriveStatusSnapshot(this.conversationState, nextTurn, nextRequests);
+ this.turnId = nextTurn.active ? nextTurn.id ?? null : null;
+ this.state = nextTurn.active ? "active" : this.deriveSessionState();
+ const nextHistoryComplete = !hasIncompleteHistory(this.conversationState);
+ const historyChanged = this.historyComplete !== undefined && this.historyComplete !== nextHistoryComplete;
+ // Settings updates are broadcast as conversation-state patches and may not
+ // change output, turn status, or pending requests. Fingerprint only the
+ // redacted projection exposed by `snapshot()` so those patches still reach
+ // the relay without leaking opaque/private state or causing per-token
+ // snapshot spam.
+ const metadataShape = stableStringify(projectSessionMetadata(this.conversationState));
+ const metadataChanged = metadataShape !== this.renderedMetadataShape;
+
+ const previousPending = new Map(this.pending);
+ const nextKeys = new Set(nextRequests.map((entry) => jsonRpcIdKey(entry.requestId)));
+ for (const key of this.optimisticallyResolved) {
+ if (!nextKeys.has(key)) this.optimisticallyResolved.delete(key);
+ }
+ const previousKeys = new Set(previousPending.keys());
+ for (const key of this.pendingTimers.keys()) {
+ if (!nextKeys.has(key)) this.clearPendingTimer(key);
+ }
+ this.pending.clear();
+ for (const rawEntry of nextRequests) {
+ const prior = previousPending.get(jsonRpcIdKey(rawEntry.requestId));
+ // Some official request records omit timestamps. Keep the first-seen
+ // deadline across patches instead of extending it on every output delta.
+ const entry = prior
+ ? { ...rawEntry, createdAt: prior.createdAt, expiresAt: prior.expiresAt ?? rawEntry.expiresAt }
+ : rawEntry;
+ const key = jsonRpcIdKey(entry.requestId);
+ if (this.optimisticallyResolved.has(key)) continue;
+ this.pending.set(key, entry);
+ this.schedulePendingExpiry(key, entry);
+ if (!previousKeys.has(key)) this.emitRequest(entry);
+ }
+ for (const key of previousKeys) {
+ if (nextKeys.has(key) || this.optimisticallyResolved.has(key)) continue;
+ const old = previousPending.get(key);
+ if (old) this.emit({ type: old.approval ? "approval.resolved" : "input.resolved", threadId: old.threadId ?? this.threadId ?? undefined, turnId: old.turnId, requestId: old.requestId, payload: { requestId: asJsonValue(old.requestId), method: old.method } });
+ }
+
+ const rendered = renderConversationOutput(this.conversationState, this.options.maxOutputTailChars);
+ const output = rendered.text;
+ const messageShape = renderedMessageShape(rendered.messages);
+ const messagesChanged = messageShape !== this.renderedMessageShape;
+ const messagesPatch = messagesChanged
+ ? renderedMessagesPatch(this.outputMessages, rendered.messages)
+ : undefined;
+ const subagentShape = renderedSubagentShape(rendered.subagents);
+ const subagentsChanged = subagentShape !== this.renderedSubagentShape;
+ if (output !== this.renderedOutput || rendered.totalLength !== this.renderedOutputLength || messagesChanged || subagentsChanged) {
+ const delta = this.renderedOutput
+ ? appendOnlyOutputDelta(
+ this.renderedOutput,
+ this.renderedOutputLength,
+ output,
+ rendered.totalLength,
+ this.renderedOutputWasTruncated,
+ )
+ : undefined;
+ if (!this.renderedOutput || delta === undefined || (!delta && (messagesChanged || subagentsChanged))) {
+ this.emit({ type: "output.snapshot", threadId: this.threadId ?? undefined, turnId: this.turnId ?? undefined, payload: { stream: "codex", text: output, messages: asJsonValue(rendered.messages), subagents: asJsonValue(rendered.subagents), structureChanged: true, encoding: "utf8" } });
+ } else {
+ if (delta || subagentsChanged) this.emit({
+ type: "output.chunk",
+ threadId: this.threadId ?? undefined,
+ turnId: this.turnId ?? undefined,
+ payload: {
+ stream: "codex",
+ text: delta,
+ // Keep the append-only field for older relays, but include the
+ // authoritative projection so a browser can preserve item
+ // boundaries while reasoning/commands/edits stream in.
+ outputTail: output,
+ ...(messagesPatch ? { messagesPatch: asJsonValue(messagesPatch) } : {}),
+ subagents: asJsonValue(rendered.subagents),
+ structureChanged: messagesChanged || subagentsChanged,
+ encoding: "utf8",
+ },
+ });
+ }
+ this.renderedOutput = output;
+ this.renderedOutputLength = rendered.totalLength;
+ this.renderedOutputWasTruncated = rendered.truncated;
+ this.renderedMessageShape = messageShape;
+ this.renderedSubagentShape = subagentShape;
+ this.outputTail = output;
+ this.outputMessages = rendered.messages;
+ this.subagents = rendered.subagents;
+ }
+
+ if (!initial && !previousTurnId && this.turnId) {
+ this.emit({ type: "task.started", threadId: this.threadId ?? undefined, turnId: this.turnId, payload: statusPayload(this.status) });
+ } else if (!initial && previousTurnId && !this.turnId) {
+ const cancelled = new Set(["cancelled", "canceled", "interrupted"]).has(normalizeStatus(nextTurn.status));
+ this.emit({ type: cancelled ? "task.cancelled" : "task.finished", threadId: this.threadId ?? undefined, turnId: previousTurnId, payload: statusPayload(this.status) });
+ } else if (!initial && !sameStatus(previousStatus, this.status)) {
+ // A turn can remain active while moving from reasoning to a command or
+ // file edit, and can enter/leave an approval wait without changing its
+ // id. Publish a dedicated status event so remote viewers do not have to
+ // infer activity from output timing.
+ this.emit({ type: "task.status", threadId: this.threadId ?? undefined, turnId: this.turnId ?? undefined, payload: statusPayload(this.status) });
+ }
+
+ this.historyComplete = nextHistoryComplete;
+ this.renderedMetadataShape = metadataShape;
+ if (initial || metadataChanged || historyChanged || previousTurnId !== this.turnId || previousState !== this.state || !sameStatus(previousStatus, this.status) || nextRequests.length !== previousKeys.size || subagentsChanged) {
+ void this.emitSnapshot();
+ }
+ }
+
+ private async loadCompleteHistoryIfNeeded(): Promise {
+ if (this.historyLoadRequested || this.historyLoadRetryTimer || this.historyLoadAttempts >= 2 || !this.threadId || !this.ownerClientId || !hasIncompleteHistory(this.conversationState)) return;
+ this.clearHistoryLoadRetryTimer();
+ // Keep the request target stable. A stream event from another owner can
+ // arrive while the owner is loading history; using the mutable fields
+ // below would otherwise wait for (or acknowledge) the wrong stream.
+ const threadId = this.threadId;
+ const ownerClientId = this.ownerClientId;
+ const generation = this.historyLoadGeneration;
+ this.historyLoadRequested = true;
+ this.historyLoadAttempts += 1;
+ try {
+ const result = await this.client.loadCompleteHistory(threadId, {
+ ownerClientId,
+ timeoutMs: this.options.followTimeoutMs,
+ });
+ // The socket may close (or the owner may hand the conversation to a new
+ // client) while the request is in flight. Do not install a fresh waiter
+ // after dispose/close, where nobody could clear it.
+ if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
+ const requestedRevision = extractRevision(result);
+ // The owner acknowledges the load request before broadcasting its new
+ // state. Wait for that revision so the relay's first snapshot contains
+ // the complete history rather than the old paginated tail.
+ if (requestedRevision !== undefined && (this.revision ?? 0) < requestedRevision) {
+ await this.waitForRevision(threadId, ownerClientId, requestedRevision, this.options.followTimeoutMs);
+ }
+ // A session switch can resolve the old revision waiter while installing
+ // a new history request. Never let that old continuation clear or retry
+ // the new conversation's loading state.
+ if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
+ this.historyLoadRequested = false;
+ if (this.started
+ && this.threadId === threadId
+ && this.ownerClientId === ownerClientId
+ && hasIncompleteHistory(this.conversationState)
+ && this.historyLoadAttempts < 2) {
+ void this.loadCompleteHistoryIfNeeded();
+ }
+ } catch (error) {
+ // History loading is an optional read enhancement. The live tail remains
+ // usable when an older extension does not implement this request.
+ this.options.logger?.debug?.("Unable to load complete Codex history", error);
+ if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
+ this.historyLoadRequested = false;
+ if (isTransientHistoryLoadError(error)
+ && this.historyLoadAttempts < 2
+ && hasIncompleteHistory(this.conversationState)) {
+ this.scheduleHistoryLoadRetry(threadId, ownerClientId, generation);
+ }
+ }
+ }
+
+ private scheduleHistoryLoadRetry(threadId: string, ownerClientId: string, generation: number): void {
+ if (this.historyLoadRetryTimer || !this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
+ this.historyLoadRetryTimer = setTimeout(() => {
+ this.historyLoadRetryTimer = undefined;
+ if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)
+ || this.historyLoadRequested
+ || this.historyLoadAttempts >= 2
+ || !hasIncompleteHistory(this.conversationState)) return;
+ void this.loadCompleteHistoryIfNeeded();
+ }, HISTORY_LOAD_RETRY_DELAY_MS);
+ }
+
+ private isCurrentHistoryLoad(threadId: string, ownerClientId: string, generation: number): boolean {
+ return !this.disposed
+ && this.started
+ && this.historyLoadGeneration === generation
+ && this.threadId === threadId
+ && this.ownerClientId === ownerClientId;
+ }
+
+ private clearHistoryLoadRetryTimer(): void {
+ if (!this.historyLoadRetryTimer) return;
+ clearTimeout(this.historyLoadRetryTimer);
+ this.historyLoadRetryTimer = undefined;
+ }
+
+ private resetHistoryLoading(): void {
+ this.clearHistoryLoadRetryTimer();
+ this.historyLoadRequested = false;
+ this.historyLoadAttempts = 0;
+ this.historyLoadGeneration += 1;
+ }
+
+ private emitRequest(entry: RequestEntry): void {
+ const isInput = INPUT_METHODS.has(entry.method);
+ this.emit({
+ type: isInput ? "input.requested" : APPROVAL_METHODS.has(entry.method) ? "approval.requested" : "server.requested",
+ threadId: entry.threadId ?? this.threadId ?? undefined,
+ turnId: entry.turnId,
+ requestId: entry.requestId,
+ payload: {
+ requestId: asJsonValue(entry.requestId),
+ method: entry.method,
+ params: redactJson(entry.params),
+ ...(entry.approval ? {
+ action: entry.approval.action,
+ risk: entry.approval.risk,
+ summary: entry.approval.summary,
+ ...(entry.approval.commandHash ? { commandHash: entry.approval.commandHash } : {}),
+ } : {}),
+ ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
+ },
+ raw: redactJson({ requestId: entry.requestId, method: entry.method, params: entry.params }),
+ });
+ }
+
+ private async emitSnapshot(): Promise {
+ const snapshot = await this.snapshot();
+ this.emit({ type: "session.snapshot", threadId: snapshot.threadId ?? undefined, turnId: snapshot.turnId ?? undefined, payload: asJsonObject(snapshot) });
+ }
+
+ private deriveSessionState(): string {
+ const runtime = this.conversationState.threadRuntimeStatus;
+ if (isRecord(runtime) && typeof runtime.type === "string") {
+ const status = normalizeStatus(runtime.type);
+ if (!TERMINAL_TURN_STATES.has(status) && status !== "idle" && status !== "ready") return runtime.type;
+ }
+ return this.snapshotSeen ? "idle" : "syncing";
+ }
+
+ private waitForSnapshot(threadId: string, timeoutMs: number, ownerClientId?: string): Promise {
+ this.clearSnapshotWaiter();
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ if (this.snapshotWaiter?.threadId === threadId
+ && this.snapshotWaiter.ownerClientId === ownerClientId) this.snapshotWaiter = undefined;
+ reject(new Error(`Timed out waiting for a snapshot from VS Code conversation ${threadId}`));
+ }, timeoutMs);
+ this.snapshotWaiter = { threadId, ownerClientId, resolve, reject, timer };
+ });
+ }
+
+ /** Rehydrate the adapter's last owner-validated state after failed navigation. */
+ private restoreCachedConversationProjection(
+ cached: ConversationStreamState | undefined,
+ fallbackOwnerClientId: string | null,
+ ): boolean {
+ if (!cached || !cached.conversationState) return false;
+ this.ownerClientId = cached.ownerClientId || fallbackOwnerClientId;
+ this.revision = cached.revision;
+ this.conversationState = cloneObject(cached.conversationState);
+ this.processConversationState(true);
+ this.state = this.deriveSessionState();
+ return true;
+ }
+
+ private waitForRevision(threadId: string, ownerClientId: string, revision: number, timeoutMs: number): Promise {
+ this.clearRevisionWaiter();
+ if (this.threadId === threadId && this.ownerClientId === ownerClientId && (this.revision ?? 0) >= revision) return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ if (this.revisionWaiter?.threadId === threadId
+ && this.revisionWaiter.ownerClientId === ownerClientId
+ && this.revisionWaiter.revision === revision) this.revisionWaiter = undefined;
+ reject(new Error(`Timed out waiting for Codex conversation revision ${revision}`));
+ }, timeoutMs);
+ this.revisionWaiter = { threadId, ownerClientId, revision, resolve, reject, timer };
+ });
+ }
+
+ private clearSnapshotWaiter(error?: Error): void {
+ const waiter = this.snapshotWaiter;
+ if (!waiter) return;
+ clearTimeout(waiter.timer);
+ this.snapshotWaiter = undefined;
+ if (error) waiter.reject(error);
+ else waiter.resolve();
+ }
+
+ private clearRevisionWaiter(error?: Error): void {
+ const waiter = this.revisionWaiter;
+ if (!waiter) return;
+ clearTimeout(waiter.timer);
+ this.revisionWaiter = undefined;
+ if (error) waiter.reject(error);
+ else waiter.resolve();
+ }
+
+ /** Clear all projections that belong to the previous conversation. */
+ private resetConversationProjection(): void {
+ this.clearRevisionWaiter();
+ this.revision = null;
+ this.conversationState = {};
+ this.turnId = null;
+ this.status = {
+ activity: "idle",
+ turnStatus: "idle",
+ activeFlags: [],
+ startedAtMs: null,
+ durationMs: null,
+ workedDurationMs: null,
+ elapsedMs: null,
+ firstTurnWorkItemStartedAtMs: null,
+ finalAssistantStartedAtMs: null,
+ };
+ this.renderedOutput = "";
+ this.renderedOutputLength = 0;
+ this.renderedOutputWasTruncated = false;
+ this.renderedMessageShape = "";
+ this.outputTail = "";
+ this.outputMessages = [];
+ this.subagents = [];
+ this.renderedSubagentShape = "";
+ this.renderedMetadataShape = "";
+ this.snapshotSeen = false;
+ this.historyComplete = undefined;
+ this.resetHistoryLoading();
+ }
+
+ private handleClose(error?: Error): void {
+ if (!this.started || this.disposed) return;
+ const closedThreadId = this.threadId;
+ const closedOwnerClientId = this.ownerClientId;
+ this.sessionLifecycleGeneration += 1;
+ this.clearWaitingDiscoveryTimer();
+ this.waitingForSession = false;
+ this.started = false;
+ this.state = "disconnected";
+ this.clearPendingVscodeFollow();
+ this.vscodeRouteActiveThreadId = null;
+ this.vscodeRouteAwaitingSelection = false;
+ this.vscodeRouteClientId = null;
+ this.vscodeRouteCandidates.clear();
+ this.activeVscodeSelection = undefined;
+ this.clearSnapshotWaiter(error ?? new Error("Codex IPC socket closed"));
+ this.clearRevisionWaiter(error ?? new Error("Codex IPC socket closed"));
+ for (const timer of this.pendingTimers.values()) clearTimeout(timer);
+ this.pendingTimers.clear();
+ this.pendingExpiryAt.clear();
+ const pending = [...this.pending.values()];
+ this.pending.clear();
+ this.optimisticallyResolved.clear();
+ // Do not retain a conversation projection after the private IPC owner has
+ // gone away. A later snapshot request must report a disconnected, empty
+ // follower rather than stale messages that can no longer be controlled.
+ this.resetConversationProjection();
+ this.threadId = null;
+ this.ownerClientId = null;
+ this.status = {
+ ...this.status,
+ activity: "idle",
+ turnStatus: "disconnected",
+ activeFlags: [],
+ };
+ for (const entry of pending) {
+ this.emit({
+ type: entry.approval ? "approval.expired" : "input.expired",
+ threadId: entry.threadId ?? closedThreadId ?? undefined,
+ turnId: entry.turnId,
+ requestId: entry.requestId,
+ payload: { requestId: asJsonValue(entry.requestId), reason: error?.message ?? "IPC socket closed" },
+ });
+ }
+ this.emit({
+ type: "connection.closed",
+ threadId: closedThreadId ?? undefined,
+ payload: {
+ message: error?.message ?? "IPC socket closed",
+ ...(closedOwnerClientId ? { ownerClientId: closedOwnerClientId } : {}),
+ },
+ });
+ }
+
+ private ensureAttached(): void {
+ if (!this.started) throw new Error("Codex remote bridge is not attached to a VS Code Codex session");
+ if (this.waitingForSession || this.state === "waiting_for_host") {
+ throw new Error("waiting_for_session: 请先在 VS Code 打开一个 Codex 会话");
+ }
+ if (!this.threadId || !this.ownerClientId) throw new Error("No existing Codex conversation owner is attached");
+ }
+
+ private ensureStarted(): void {
+ if (!this.started) throw new Error("Codex remote bridge is not connected to the VS Code IPC host");
+ }
+
+ private ensureInteractiveReady(): void {
+ this.ensureAttached();
+ if (this.sessionSwitching || this.state === "syncing") throw new Error("Codex session switch is still in progress");
+ }
+
+ private emit(event: AgentEvent): void {
+ // Every relay event carries the latest typed execution projection. Keep
+ // the same values in payload for older consumers that only inspect the
+ // untyped event envelope.
+ const eventStatus = event.status ?? this.status;
+ const normalized: AgentEvent = {
+ ...event,
+ status: { ...eventStatus, activeFlags: [...eventStatus.activeFlags] },
+ payload: { ...statusPayload(eventStatus), ...event.payload },
+ };
+ for (const listener of this.listeners) {
+ try {
+ listener(normalized);
+ } catch (error) {
+ this.options.logger?.warn?.("Codex IPC adapter listener failed", error);
+ }
+ }
+ }
+
+ private toWireResponse(entry: RequestEntry, decision: "allow" | "deny" | "cancel", reason?: string, response?: JsonValue): JsonValue {
+ if (entry.method === "item/permissions/requestApproval") {
+ const supplied = isRecord(response) ? response : {};
+ const requested = isRecord(supplied.permissions) ? supplied.permissions : decision === "allow" && isRecord(entry.params.permissions) ? entry.params.permissions : {};
+ const permissions: JsonObject = {};
+ for (const [key, value] of Object.entries(requested)) if (value !== null && value !== undefined) permissions[key] = asJsonValue(value);
+ return { permissions, scope: supplied.scope === "session" ? "session" : "turn", ...(typeof supplied.strictAutoReview === "boolean" ? { strictAutoReview: supplied.strictAutoReview } : {}) };
+ }
+ if (entry.method === "item/tool/requestUserInput") {
+ return normalizeUserInputResponse(response);
+ }
+ if (entry.method === "mcpServer/elicitation/request") {
+ if (isRecord(response) && typeof response.action === "string") return response;
+ return { action: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline", content: null, _meta: null };
+ }
+ const suppliedDecision = isRecord(response) && Object.prototype.hasOwnProperty.call(response, "decision") ? response.decision : undefined;
+ if (suppliedDecision !== undefined) return normalizeFollowerDecision(entry.method, suppliedDecision, decision, reason);
+ if (entry.method === "applyPatchApproval" || entry.method === "execCommandApproval") {
+ if (decision === "allow") return "approved";
+ if (decision === "cancel") return "abort";
+ return { denied: { rejection: reason || "Denied remotely" } };
+ }
+ return decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline";
+ }
+}
+
+function normalizeFollowerDecision(method: string, supplied: unknown, fallback: "allow" | "deny" | "cancel", reason?: string): JsonValue {
+ // The relay accepts compatibility aliases, while the private follower
+ // methods use the app-server's method-specific wire vocabulary.
+ if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") {
+ if (supplied === "approved" || supplied === "approved_for_session" || supplied === "approved_mcp_policy_amendment") {
+ return supplied === "approved" ? "accept" : supplied === "approved_for_session" ? "acceptForSession" : "accept";
+ }
+ if (supplied === "denied" || supplied === "deny") return "decline";
+ if (supplied === "abort") return "cancel";
+ return asJsonValue(supplied);
+ }
+ if (method === "applyPatchApproval" || method === "execCommandApproval") {
+ if (supplied === "accept") return "approved";
+ if (supplied === "acceptForSession") return "approved_for_session";
+ if (supplied === "decline" || supplied === "deny") return { denied: { rejection: reason || "Denied remotely" } };
+ if (supplied === "cancel") return "abort";
+ return asJsonValue(supplied);
+ }
+ // If a caller supplied a generic wrapper with no method-specific alias,
+ // retain the ordinary fallback selected by the adapter.
+ return asJsonValue(supplied ?? (fallback === "allow" ? "accept" : fallback === "cancel" ? "cancel" : "decline"));
+}
+
+/** Normalize the official tool-input shape: answers[id].answers is a string array. */
+function normalizeUserInputResponse(response: JsonValue | undefined): JsonObject {
+ const hasOuterAnswers = isRecord(response) && isRecord(response.answers);
+ const source = hasOuterAnswers ? response.answers as Record : isRecord(response) ? response : {};
+ const answers: JsonObject = {};
+ for (const [questionId, raw] of Object.entries(source)) {
+ if (isRecord(raw) && Array.isArray(raw.answers)) {
+ answers[questionId] = { answers: raw.answers.map(asJsonValue) };
+ } else if (Array.isArray(raw)) {
+ answers[questionId] = { answers: raw.map(asJsonValue) };
+ } else if (typeof raw === "string") {
+ answers[questionId] = { answers: [raw] };
+ } else if (raw !== undefined && raw !== null) {
+ answers[questionId] = { answers: [asJsonValue(raw)] };
+ } else {
+ answers[questionId] = { answers: [] };
+ }
+ }
+ return { answers };
+}
+
+interface Candidate {
+ id: string;
+ mtime: number;
+ updatedAtMs?: number;
+ cwd?: string;
+ title?: string;
+ priority: number;
+}
+
+async function recentVscodeThreadCandidates(root: string, preferredCwds: string[] = []): Promise {
+ const files: Array<{ file: string; mtime: number; id: string }> = [];
+ async function visit(directory: string, depth: number): Promise {
+ if (depth > 3) return;
+ let entries;
+ try { entries = await fs.readdir(directory, { withFileTypes: true }); } catch { return; }
+ await Promise.all(entries.map(async (entry) => {
+ const full = path.join(directory, entry.name);
+ if (entry.isDirectory()) return visit(full, depth + 1);
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return;
+ // Codex has used UUIDv7 rollouts (the current hyphenated form), compact
+ // UUIDs, and 26-character ULIDs across desktop/VS Code builds. Keep the
+ // suffix strict so an arbitrary prompt-like filename cannot become a
+ // selectable session.
+ const match = entry.name.match(/(?:^|-)((?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[0-9a-z]{26}))(?:_[^/]*)?\.jsonl$/i);
+ if (!match) return;
+ try { const stat = await fs.stat(full); files.push({ file: full, mtime: stat.mtimeMs, id: match[1] }); } catch { /* race */ }
+ }));
+ }
+ await visit(root, 0);
+ files.sort((a, b) => b.mtime - a.mtime);
+ const result: Candidate[] = [];
+ // Rollout directories can contain many old sessions. Read metadata from a
+ // generous bounded set so an active VS Code session is not hidden merely
+ // because desktop history was written more recently.
+ for (const file of files.slice(0, 2_000)) {
+ try {
+ const firstLine = await readFirstLine(file.file);
+ let payload: Record = {};
+ try {
+ const record = JSON.parse(firstLine) as unknown;
+ if (isRecord(record)) {
+ // Current rollouts wrap metadata in `payload`; older writers put the
+ // same fields on the first record itself. Accept only records that
+ // actually contain session identity fields so an arbitrary event
+ // record cannot become a false discovery candidate.
+ const candidate = isRecord(record.payload) ? record.payload : record;
+ if (["originator", "source", "thread_source", "cwd"].some((key) => Object.prototype.hasOwnProperty.call(candidate, key))) {
+ payload = candidate;
+ }
+ }
+ } catch {
+ // Incomplete rollout records are ignored below. A regex fallback is
+ // intentionally omitted so text inside a long prompt cannot identify
+ // an unrelated session as a VS Code conversation.
+ }
+ const originator = stringValue(payload.originator);
+ const source = stringValue(payload.source);
+ const threadSource = stringValue(payload.thread_source);
+ const cwd = stringValue(payload.cwd);
+ const title = stringValue(payload.title)
+ ?? stringValue(payload.thread_title)
+ ?? stringValue(payload.threadTitle)
+ ?? stringValue(payload.name)
+ ?? stringValue(payload.thread_name);
+ const updatedAtMs = timestampMs(payload.updated_at_ms)
+ ?? timestampMs(payload.updatedAtMs)
+ ?? timestampMs(payload.updated_at)
+ ?? timestampMs(payload.updatedAt)
+ ?? timestampMs(payload.timestamp);
+ // Subagent rollouts can use the same `codex_vscode` originator as their
+ // user-facing parent, but they are not conversations the operator opened.
+ if (threadSource === "subagent") continue;
+ // `source: vscode` is also written by Codex Desktop tasks hosted from a
+ // VS Code-shaped workspace. An explicit non-VS-Code originator must win
+ // so attach mode never follows a desktop task merely because it shares
+ // the same IPC router. Keep compatibility with older official rollouts
+ // that omitted originator altogether.
+ const official = originator === "codex_vscode";
+ const unattributedVscode = !originator && (source === "vscode" || threadSource === "vscode");
+ const isVscode = official || unattributedVscode;
+ if (!isVscode) continue;
+ // Never select a rollout produced by this bridge's legacy spawn mode.
+ if (originator === "codex-remote-collab") continue;
+ const cwdMatch = Boolean(cwd && preferredCwds.some((candidate) => samePath(candidate, cwd)));
+ const id = file.id;
+ // The bridge runs inside a VS Code workspace, so an owner in that
+ // workspace is a stronger signal than the rollout writer's originator.
+ const priority = cwdMatch ? (official ? 0 : 1) : (official ? 2 : 3);
+ if (!result.some((candidate) => candidate.id === id)) {
+ result.push({
+ id,
+ mtime: file.mtime,
+ ...(updatedAtMs !== undefined ? { updatedAtMs } : {}),
+ ...(cwd ? { cwd } : {}),
+ ...(title ? { title } : {}),
+ priority,
+ });
+ }
+ } catch { /* ignore incomplete/rotated rollout files */ }
+ }
+ result.sort((a, b) => a.priority - b.priority || b.mtime - a.mtime);
+ return result;
+}
+
+/** Read enough of a rollout file to reach its first JSONL record, bounded. */
+async function readFirstLine(fileName: string, maxBytes = 4 * 1024 * 1024): Promise {
+ const handle = await fs.open(fileName, "r");
+ const parts: Buffer[] = [];
+ let offset = 0;
+ try {
+ while (offset < maxBytes) {
+ const size = Math.min(64 * 1024, maxBytes - offset);
+ const chunk = Buffer.alloc(size);
+ const read = await handle.read(chunk, 0, size, offset);
+ if (!read.bytesRead) break;
+ const piece = chunk.subarray(0, read.bytesRead);
+ const newline = piece.indexOf(0x0a);
+ if (newline >= 0) {
+ parts.push(piece.subarray(0, newline));
+ return Buffer.concat(parts).toString("utf8");
+ }
+ parts.push(piece);
+ offset += read.bytesRead;
+ if (read.bytesRead < size) break;
+ }
+ return Buffer.concat(parts).toString("utf8");
+ } finally {
+ await handle.close();
+ }
+}
+
+interface SessionIndexEntry {
+ title?: string;
+ updatedAtMs?: number;
+ cwd?: string;
+}
+
+/** Read the bounded local index used by the official Codex recent-chat list. */
+async function readSessionIndex(fileName: string, maxBytes = 8 * 1024 * 1024): Promise