Skip to content

Commit bf10cee

Browse files
committed
Release v0.5.0 proxy reliability updates
1 parent 7e1ed55 commit bf10cee

17 files changed

Lines changed: 387 additions & 65 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
node_modules/
2+
.claude/
23
release/
34
python_dist/
45
.venv/

codex_context.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
CONTEXT_CONTROL_COMMANDS = {"ctx", "/ctx", "context", "/context"}
6+
7+
8+
def text_value(value: Any) -> str:
9+
return str(value or "")
10+
11+
12+
def is_context_control_command_text(text: Any) -> bool:
13+
return text_value(text).strip().lower() in CONTEXT_CONTROL_COMMANDS
14+
15+
16+
def is_contextual_user_text(text: Any) -> bool:
17+
trimmed = text_value(text).lstrip()
18+
lowered = trimmed.lower()
19+
return (
20+
trimmed.startswith("# AGENTS.md instructions for ")
21+
or lowered.startswith("<environment_context>")
22+
or lowered.startswith("<skills>")
23+
or lowered.startswith("<user_shell_command>")
24+
or lowered.startswith("<turn_aborted>")
25+
or lowered.startswith("<subagent_notification>")
26+
)
27+
28+
29+
def is_conversation_record(record: dict[str, Any]) -> bool:
30+
role = text_value(record.get("role")).strip()
31+
if role == "assistant":
32+
return True
33+
if role != "user":
34+
return False
35+
text = text_value(record.get("text"))
36+
return (
37+
bool(text.strip())
38+
and not is_contextual_user_text(text)
39+
and not is_context_control_command_text(text)
40+
)
41+
42+
43+
def conversation_record_count(transcript: list[dict[str, Any]]) -> int:
44+
return sum(1 for record in transcript if is_conversation_record(record))

electron/context-window.cjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const fs = require('node:fs');
44
const http = require('node:http');
55
const path = require('node:path');
66

7-
const HOST = '127.0.0.1';
7+
const HOST = process.env.HASH_CONTEXT_HOST || 'localhost';
88
const BACKEND_PORT = 8765;
99
const FRONTEND_PORT = 5174;
1010
const PROXY_PORT = 8787;
@@ -228,7 +228,7 @@ async function startBackend(root) {
228228
writeLog(`[backend:error] ${chunk.toString().trim()}`);
229229
});
230230

231-
await waitFor(BACKEND_PORT, '/api/init', 'Backend');
231+
await waitFor(BACKEND_PORT, '/api/init', 'Backend', 90000);
232232
writeLog('backend ready');
233233
}
234234

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hash-context-codex-lab",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"private": true,
55
"description": "Local context proxy and workbench companion for Codex.",
66
"author": "",
@@ -69,6 +69,7 @@
6969
},
7070
"files": [
7171
"electron/**/*",
72+
"codex_context.py",
7273
"web_server.py",
7374
"main.py",
7475
"hash.html",

proxy_server.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@
1818
from pathlib import Path
1919
from typing import Any
2020

21+
from codex_context import (
22+
conversation_record_count,
23+
is_context_control_command_text,
24+
is_contextual_user_text,
25+
)
26+
2127

22-
HOST = os.environ.get("HASH_CONTEXT_PROXY_HOST", "127.0.0.1")
28+
HOST = os.environ.get("HASH_CONTEXT_PROXY_HOST", os.environ.get("HASH_CONTEXT_HOST", "localhost"))
2329
PORT = int(os.environ.get("HASH_CONTEXT_PROXY_PORT", "8787"))
2430
OPENAI_UPSTREAM_BASE_URL = os.environ.get(
2531
"HASH_CONTEXT_OPENAI_UPSTREAM_BASE_URL",
@@ -36,7 +42,6 @@
3642
CODEX_PROXY_BASE_URL = f"http://{HOST}:{PORT}/v1"
3743
INTERNAL_CONTEXT_HEADER = "x-hash-context-internal"
3844
INTERNAL_CONTEXT_VALUE = "context-workbench"
39-
CONTEXT_CONTROL_COMMANDS = {"ctx", "/ctx", "context", "/context"}
4045
CONTEXT_CONTROL_NOTICE_TEXT = "Hash Context: opened workbench."
4146
CONTROL_PORT = int(os.environ.get("HASH_CONTEXT_CONTROL_PORT", "8790"))
4247
LOCAL_COMPACT_PROMPT_PREFIX = "You are performing a CONTEXT CHECKPOINT COMPACTION."
@@ -507,10 +512,6 @@ def flush_assistant() -> None:
507512
return records
508513

509514

510-
def is_context_control_command_text(text: str) -> bool:
511-
return str(text or "").strip().lower() in CONTEXT_CONTROL_COMMANDS
512-
513-
514515
def context_control_command_from_input(input_items: Any) -> str:
515516
transcript = input_items_to_transcript(input_items)
516517
for record in reversed(transcript):
@@ -938,7 +939,7 @@ def open_context_workbench(session_id: str) -> tuple[bool, str]:
938939
path = "/show"
939940
if session_id:
940941
path = f"{path}?session_id={urllib.parse.quote(session_id, safe='')}"
941-
conn = http.client.HTTPConnection("127.0.0.1", CONTROL_PORT, timeout=2)
942+
conn = http.client.HTTPConnection(os.environ.get("HASH_CONTEXT_HOST", "localhost"), CONTROL_PORT, timeout=2)
942943
try:
943944
conn.request("POST", path, body=b"", headers={"Content-Length": "0"})
944945
response = conn.getresponse()
@@ -1115,19 +1116,6 @@ def is_local_compact_summary_text(text: str) -> bool:
11151116
return str(text or "").startswith(f"{LOCAL_COMPACT_SUMMARY_PREFIX}\n\n")
11161117

11171118

1118-
def is_contextual_user_text(text: str) -> bool:
1119-
trimmed = str(text or "").lstrip()
1120-
lowered = trimmed.lower()
1121-
return (
1122-
trimmed.startswith("# AGENTS.md instructions for ")
1123-
or lowered.startswith("<environment_context>")
1124-
or lowered.startswith("<skills>")
1125-
or lowered.startswith("<user_shell_command>")
1126-
or lowered.startswith("<turn_aborted>")
1127-
or lowered.startswith("<subagent_notification>")
1128-
)
1129-
1130-
11311119
def is_initial_context_prefix_record(record: dict[str, Any]) -> bool:
11321120
role = str(record.get("role") or "").strip()
11331121
if role in {"system", "developer"}:
@@ -1161,6 +1149,13 @@ def with_fresh_initial_context_prefix(
11611149
return clean_transcript([*prefix, *body])
11621150

11631151

1152+
def should_replace_transcript_from_control_intercept(
1153+
existing_transcript: list[dict[str, Any]],
1154+
candidate_transcript: list[dict[str, Any]],
1155+
) -> bool:
1156+
return conversation_record_count(candidate_transcript) >= conversation_record_count(existing_transcript)
1157+
1158+
11641159
def local_compact_source_from_transcript(transcript: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
11651160
records = clean_transcript(transcript)
11661161
if not records:
@@ -1410,8 +1405,12 @@ def record_control_intercept(self, session_id: str, body: dict[str, Any], header
14101405
if session is None:
14111406
session = ProxySession(id=session_id, title=f"Codex {session_id[:8]}")
14121407
self.sessions[session_id] = session
1413-
source_transcript = strip_context_edit_notice_records(input_items_to_transcript(body.get("input")))
1414-
session.transcript = clean_transcript(source_transcript)
1408+
source_transcript = clean_transcript(
1409+
strip_context_edit_notice_records(input_items_to_transcript(body.get("input")))
1410+
)
1411+
existing_transcript = clean_transcript(session.transcript)
1412+
if should_replace_transcript_from_control_intercept(existing_transcript, source_transcript):
1413+
session.transcript = source_transcript
14151414
session.pending_transcript = None
14161415
session.local_compact_source_transcript = None
14171416
session.status = "override" if session.edited_transcript is not None else "mirror"

react_app/src/WorkbenchWindow.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { normalizeConversation, normalizeReasoningOptions } from './utils';
2626
const DEMO_SESSION_ID = 'hash-context-preview';
2727
const LIVE_REFRESH_IDLE_MS = 1600;
2828
const LIVE_REFRESH_RUNNING_MS = 800;
29+
const PENDING_CONTEXT_REFRESH_MS = 200;
30+
const PENDING_CONTEXT_REFRESH_MAX_MS = 5000;
2931
const LOCAL_EDIT_GRACE_MS = 1500;
3032
const MIN_WORKBENCH_WINDOW_WIDTH = 760;
3133
const MIN_WORKBENCH_WINDOW_HEIGHT = 520;
@@ -55,6 +57,19 @@ function isProxyBusy(status?: string, isRunning?: boolean): boolean {
5557
return Boolean(isRunning || status === 'running' || status === 'compacting');
5658
}
5759

60+
function hasPendingAssistantMessage(messages: MessageRecord[]): boolean {
61+
const lastAssistant = [...messages].reverse().find((message) => message.role === 'an');
62+
if (!lastAssistant) {
63+
return false;
64+
}
65+
66+
return Boolean(
67+
lastAssistant.pending
68+
|| (!lastAssistant.text.trim() && lastAssistant.blocks.some((block) => block.kind === 'thinking'))
69+
|| lastAssistant.blocks.some((block) => block.kind === 'reasoning' && block.status === 'streaming'),
70+
);
71+
}
72+
5873
type LoadInitOptions = {
5974
silent?: boolean;
6075
targetSessionId?: string;
@@ -530,6 +545,28 @@ export default function WorkbenchWindow() {
530545
);
531546

532547
const currentPendingRestore = sessionId ? pendingRestores[sessionId] || null : null;
548+
const hasPendingAssistant = useMemo(() => hasPendingAssistantMessage(messages), [messages]);
549+
550+
useEffect(() => {
551+
if (!hasPendingAssistant) {
552+
return undefined;
553+
}
554+
555+
const startedAt = Date.now();
556+
const intervalId = window.setInterval(() => {
557+
if (Date.now() - startedAt >= PENDING_CONTEXT_REFRESH_MAX_MS) {
558+
window.clearInterval(intervalId);
559+
return;
560+
}
561+
if (Date.now() - lastLocalEditAtRef.current < LOCAL_EDIT_GRACE_MS) {
562+
return;
563+
}
564+
565+
void loadInit({ silent: true });
566+
}, PENDING_CONTEXT_REFRESH_MS);
567+
568+
return () => window.clearInterval(intervalId);
569+
}, [hasPendingAssistant, loadInit]);
533570

534571
const commitContextConversation = useCallback(
535572
async (

react_app/src/components/ContextMapSidebar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,12 @@ function areScrollMetricsEqual(left: ScrollMetrics, right: ScrollMetrics) {
172172
function canExpandMessage(record: MessageRecord, previewText: string, isPreviewTruncated: boolean) {
173173
void previewText;
174174
const textValue = record.text || '';
175+
const trimmedTextValue = textValue.trim();
175176
const hasStructuredContent = Boolean(
176177
record.attachments.length
177178
|| record.toolEvents.length
178179
|| record.blocks.length > 1
179-
|| /\r?\n/.test(textValue),
180+
|| /\r?\n/.test(trimmedTextValue),
180181
);
181182

182183
return hasStructuredContent || isPreviewTruncated;

react_app/vite.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ export default defineConfig(({ command }) => ({
2222
},
2323
proxy: {
2424
'/api/proxy/sessions': {
25-
target: 'http://127.0.0.1:8787',
25+
target: 'http://localhost:8787',
2626
changeOrigin: true,
2727
},
2828
'/api': {
29-
target: 'http://127.0.0.1:8765',
29+
target: 'http://localhost:8765',
3030
changeOrigin: true,
3131
},
3232
},

scripts/codex-context-hook.ps1

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ try {
88
} catch {
99
}
1010

11+
$loopbackHost = if ($env:HASH_CONTEXT_HOST) { $env:HASH_CONTEXT_HOST } else { "localhost" }
12+
1113
function Write-HookJson {
1214
param(
1315
[hashtable] $Payload
@@ -134,7 +136,7 @@ function Sync-LocalCodexSession {
134136

135137
try {
136138
$payload = @{ session_id = $SessionId; title = "Codex $($SessionId.Substring(0, 8))" } | ConvertTo-Json -Compress
137-
Invoke-WebRequest -Uri "http://127.0.0.1:8765/api/codex-local-session-sync" -Method Post -Body $payload -ContentType "application/json" -UseBasicParsing -TimeoutSec 8 | Out-Null
139+
Invoke-WebRequest -Uri "http://${loopbackHost}:8765/api/codex-local-session-sync" -Method Post -Body $payload -ContentType "application/json" -UseBasicParsing -TimeoutSec 8 | Out-Null
138140
Write-HookLog "local-session-sync ok session_id=$SessionId"
139141
} catch {
140142
Write-HookLog "local-session-sync failed session_id=$SessionId error=$($_.Exception.Message)"
@@ -169,7 +171,7 @@ function Write-BackgroundLog {
169171
`$sessionId = '$escapedSessionId'
170172
try {
171173
`$payload = @{ session_id = `$sessionId; title = "Codex `$(`$sessionId.Substring(0, 8))" } | ConvertTo-Json -Compress
172-
Invoke-WebRequest -Uri "http://127.0.0.1:8765/api/codex-local-session-sync" -Method Post -Body `$payload -ContentType "application/json" -UseBasicParsing -TimeoutSec 20 | Out-Null
174+
Invoke-WebRequest -Uri "http://${loopbackHost}:8765/api/codex-local-session-sync" -Method Post -Body `$payload -ContentType "application/json" -UseBasicParsing -TimeoutSec 20 | Out-Null
173175
Write-BackgroundLog "local-session-sync ok session_id=`$sessionId"
174176
} catch {
175177
Write-BackgroundLog "local-session-sync failed session_id=`$sessionId error=`$(`$_.Exception.Message)"
@@ -194,7 +196,7 @@ function Consume-ContextEditMarker {
194196

195197
try {
196198
$payload = @{ session_id = $SessionId } | ConvertTo-Json -Compress
197-
$response = Invoke-WebRequest -Uri "http://127.0.0.1:8765/api/context-edit-marker-consume" -Method Post -Body $payload -ContentType "application/json; charset=utf-8" -UseBasicParsing -TimeoutSec 2
199+
$response = Invoke-WebRequest -Uri "http://${loopbackHost}:8765/api/context-edit-marker-consume" -Method Post -Body $payload -ContentType "application/json; charset=utf-8" -UseBasicParsing -TimeoutSec 2
198200
$body = $response.Content | ConvertFrom-Json
199201
if ($body -and $body.marker) {
200202
Write-HookLog "context-edit-marker consumed session_id=$SessionId marker=$($response.Content)"
@@ -262,7 +264,7 @@ try {
262264
if (-not $sessionId) {
263265
$sessionId = Find-LatestHistorySessionId -Prompt $prompt
264266
}
265-
$showUrl = "http://127.0.0.1:$controlPort/show"
267+
$showUrl = "http://${loopbackHost}:$controlPort/show"
266268
if ($sessionId) {
267269
$showUrl = "$showUrl`?session_id=$([uri]::EscapeDataString($sessionId))"
268270
}

0 commit comments

Comments
 (0)