Skip to content

Commit 13ee697

Browse files
HaShiSharkclaude
andcommitted
Release v0.6.0 startup reliability fixes
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2 parents 2c633f6 + bf10cee commit 13ee697

16 files changed

Lines changed: 367 additions & 83 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/

electron/context-window.cjs

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ 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';
8+
const PROBE_HOST = process.env.HASH_CONTEXT_PROBE_HOST || (HOST === 'localhost' ? '127.0.0.1' : HOST);
89
const BACKEND_PORT = 8765;
910
const FRONTEND_PORT = 5174;
1011
const PROXY_PORT = 8787;
@@ -36,11 +37,11 @@ function writeLog(message) {
3637
);
3738
}
3839

39-
function requestOk(port, pathname = '/') {
40+
function requestOk(port, pathname = '/', hostname = HOST) {
4041
return new Promise((resolve) => {
4142
const req = http.get(
4243
{
43-
hostname: HOST,
44+
hostname,
4445
port,
4546
path: pathname,
4647
timeout: 1000,
@@ -140,33 +141,66 @@ function startControlServer() {
140141
});
141142
}
142143

143-
async function waitFor(port, pathname, label, timeoutMs = 30000) {
144+
async function waitFor(port, pathname, label, timeoutMs = 30000, hostname = HOST) {
144145
const startedAt = Date.now();
145146

146147
while (Date.now() - startedAt < timeoutMs) {
147-
if (await requestOk(port, pathname)) {
148+
if (await requestOk(port, pathname, hostname)) {
148149
return;
149150
}
150151
await new Promise((resolve) => setTimeout(resolve, 300));
151152
}
152153

153-
throw new Error(`${label} did not become ready on ${HOST}:${port}.`);
154+
throw new Error(`${label} did not become ready on ${hostname}:${port}.`);
154155
}
155156

156-
function pythonCommand(root) {
157+
function pythonCandidateWorks(candidate) {
158+
const result = spawnSync(
159+
candidate.command,
160+
[...candidate.args, '-c', 'import dotenv, zstandard'],
161+
{ encoding: 'utf8', timeout: 5000, windowsHide: true },
162+
);
163+
return result.status === 0;
164+
}
165+
166+
function sourcePythonCandidates(root) {
157167
const localPython = process.platform === 'win32'
158168
? path.join(root, '.venv', 'Scripts', 'python.exe')
159169
: path.join(root, '.venv', 'bin', 'python');
160-
const fallbackPython = process.platform === 'win32' ? 'python' : 'python3';
161-
return fs.existsSync(localPython) ? localPython : fallbackPython;
170+
const candidates = [];
171+
172+
if (process.env.HASH_CONTEXT_PYTHON) {
173+
candidates.push({ command: process.env.HASH_CONTEXT_PYTHON, args: [] });
174+
}
175+
if (fs.existsSync(localPython)) {
176+
candidates.push({ command: localPython, args: [] });
177+
}
178+
if (process.platform === 'win32') {
179+
candidates.push({ command: 'py', args: ['-3'] });
180+
candidates.push({ command: 'python', args: [] });
181+
} else {
182+
candidates.push({ command: 'python3', args: [] });
183+
candidates.push({ command: 'python', args: [] });
184+
}
185+
186+
return candidates;
187+
}
188+
189+
function pythonScriptCommand(root, scriptName) {
190+
for (const candidate of sourcePythonCandidates(root)) {
191+
if (pythonCandidateWorks(candidate)) {
192+
return { command: candidate.command, args: [...candidate.args, scriptName] };
193+
}
194+
}
195+
throw new Error('No usable Python runtime found. Run npm run setup:python or set HASH_CONTEXT_PYTHON to a Python with the project dependencies installed.');
162196
}
163197

164198
function pythonServerCommand(root, scriptName, exeName) {
165199
const preferSource =
166200
process.env.HASH_CONTEXT_PREFER_SOURCE_SERVERS === '1' ||
167201
(!app.isPackaged && process.env.HASH_CONTEXT_USE_BUNDLED_PYTHON !== '1');
168202
if (preferSource) {
169-
return { command: pythonCommand(root), args: [scriptName] };
203+
return pythonScriptCommand(root, scriptName);
170204
}
171205

172206
const candidates = process.platform === 'win32'
@@ -185,7 +219,7 @@ function pythonServerCommand(root, scriptName, exeName) {
185219
}
186220
}
187221

188-
return { command: pythonCommand(root), args: [scriptName] };
222+
return pythonScriptCommand(root, scriptName);
189223
}
190224

191225
function cleanEnv(extra = {}) {
@@ -200,7 +234,7 @@ function cleanEnv(extra = {}) {
200234

201235
async function startBackend(root) {
202236
writeLog('checking backend');
203-
if (await requestOk(BACKEND_PORT, '/api/init')) {
237+
if (await requestOk(BACKEND_PORT, '/api/health', PROBE_HOST)) {
204238
writeLog('backend already running');
205239
return;
206240
}
@@ -228,13 +262,13 @@ async function startBackend(root) {
228262
writeLog(`[backend:error] ${chunk.toString().trim()}`);
229263
});
230264

231-
await waitFor(BACKEND_PORT, '/api/init', 'Backend');
265+
await waitFor(BACKEND_PORT, '/api/health', 'Backend', 30000, PROBE_HOST);
232266
writeLog('backend ready');
233267
}
234268

235269
async function startProxy(root) {
236270
writeLog('checking proxy');
237-
if (await requestOk(PROXY_PORT, '/api/proxy/sessions')) {
271+
if (await requestOk(PROXY_PORT, '/api/proxy/health', PROBE_HOST)) {
238272
writeLog('proxy already running');
239273
return;
240274
}
@@ -262,7 +296,7 @@ async function startProxy(root) {
262296
writeLog(`[proxy:error] ${chunk.toString().trim()}`);
263297
});
264298

265-
await waitFor(PROXY_PORT, '/api/proxy/sessions', 'Proxy');
299+
await waitFor(PROXY_PORT, '/api/proxy/health', 'Proxy', 30000, PROBE_HOST);
266300
writeLog('proxy ready');
267301
}
268302

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: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
22
"name": "hash-context-codex-lab",
3-
"version": "0.4.0",
3+
"version": "0.6.0",
44
"private": true,
55
"description": "Local context proxy and workbench companion for Codex.",
66
"author": "",
77
"main": "electron/context-window.cjs",
88
"scripts": {
99
"start": "npm run start:backend",
10-
"setup:python": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"if (-not (Test-Path '.venv\\Scripts\\python.exe')) { python -m venv .venv }; .venv\\Scripts\\python.exe -m pip install --upgrade pip; .venv\\Scripts\\python.exe -m pip install -r requirements.txt\"",
11-
"start:backend": "if exist .venv\\Scripts\\python.exe (.venv\\Scripts\\python.exe web_server.py) else (python web_server.py)",
12-
"proxy": "if exist .venv\\Scripts\\python.exe (.venv\\Scripts\\python.exe proxy_server.py) else (python proxy_server.py)",
10+
"setup:python": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"if (-not (Test-Path '.venv\\Scripts\\python.exe')) { py -3 -m venv .venv }; .venv\\Scripts\\python.exe -m pip install --upgrade pip; .venv\\Scripts\\python.exe -m pip install -r requirements.txt\"",
11+
"start:backend": "if exist .venv\\Scripts\\python.exe (.venv\\Scripts\\python.exe web_server.py) else (py -3 web_server.py)",
12+
"proxy": "if exist .venv\\Scripts\\python.exe (.venv\\Scripts\\python.exe proxy_server.py) else (py -3 proxy_server.py)",
1313
"codex": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/codex-with-context.ps1",
1414
"ctx:install": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/codex-ctx-proxy.ps1 install",
1515
"ctx:proxy:on": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/codex-ctx-proxy.ps1 on",
@@ -28,7 +28,7 @@
2828
"dev": "vite --config react_app/vite.config.ts",
2929
"dev:react": "vite --config react_app/vite.config.ts",
3030
"typecheck": "tsc -p react_app/tsconfig.json --noEmit",
31-
"test:compact-proxy": "if exist .venv\\Scripts\\python.exe (.venv\\Scripts\\python.exe scripts/test_compact_proxy.py) else (python scripts/test_compact_proxy.py)",
31+
"test:compact-proxy": "if exist .venv\\Scripts\\python.exe (.venv\\Scripts\\python.exe scripts/test_compact_proxy.py) else (py -3 scripts/test_compact_proxy.py)",
3232
"build": "npm run build:react",
3333
"build:react": "vite build --config react_app/vite.config.ts",
3434
"build:python": "node scripts/build-python.mjs",
@@ -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: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
)
2626

2727

28-
HOST = os.environ.get("HASH_CONTEXT_PROXY_HOST", "127.0.0.1")
28+
29+
HOST = os.environ.get("HASH_CONTEXT_PROXY_HOST", os.environ.get("HASH_CONTEXT_HOST", "localhost"))
2930
PORT = int(os.environ.get("HASH_CONTEXT_PROXY_PORT", "8787"))
3031
OPENAI_UPSTREAM_BASE_URL = os.environ.get(
3132
"HASH_CONTEXT_OPENAI_UPSTREAM_BASE_URL",
@@ -939,7 +940,7 @@ def open_context_workbench(session_id: str) -> tuple[bool, str]:
939940
path = "/show"
940941
if session_id:
941942
path = f"{path}?session_id={urllib.parse.quote(session_id, safe='')}"
942-
conn = http.client.HTTPConnection("127.0.0.1", CONTROL_PORT, timeout=2)
943+
conn = http.client.HTTPConnection(os.environ.get("HASH_CONTEXT_HOST", "localhost"), CONTROL_PORT, timeout=2)
943944
try:
944945
conn.request("POST", path, body=b"", headers={"Content-Length": "0"})
945946
response = conn.getresponse()
@@ -1317,14 +1318,17 @@ def begin_request(self, session_id: str, body: dict[str, Any], headers: dict[str
13171318
merged_body_transcript,
13181319
)
13191320
if input_items_end_with_tool_output(body.get("input")):
1320-
session.pending_transcript = with_running_assistant(source_transcript)
1321+
session.edited_transcript = forwarded_transcript
1322+
session.pending_transcript = with_running_assistant(forwarded_transcript)
1323+
request_body["input"] = drop_unpaired_tool_items(transcript_to_input_items(forwarded_transcript))
1324+
request_body.pop("previous_response_id", None)
13211325
session.status = "running"
13221326
session.last_error = ""
13231327
session.updated_at = utc_timestamp()
13241328
session.request_log.append(
13251329
{
13261330
"created_at": session.updated_at,
1327-
"kind": "tool_output_passthrough",
1331+
"kind": "override_tool_output_rewrite",
13281332
"headers": {key: value for key, value in headers.items() if key.lower().startswith("x-")},
13291333
"body": body,
13301334
"forwarded_body": request_body,
@@ -1728,6 +1732,17 @@ def latest_user_turn_tail(source_transcript: list[dict[str, Any]]) -> list[dict[
17281732
return copy.deepcopy(source[start:])
17291733

17301734

1735+
def latest_conversation_turn_tail(source_transcript: list[dict[str, Any]]) -> list[dict[str, Any]]:
1736+
source = clean_transcript(source_transcript)
1737+
for index in range(len(source) - 1, -1, -1):
1738+
if str(source[index].get("role") or "") == "user" and not is_initial_context_prefix_record(source[index]):
1739+
start = index
1740+
while start > 0 and is_initial_context_prefix_record(source[start - 1]):
1741+
start -= 1
1742+
return copy.deepcopy(source[start:])
1743+
return []
1744+
1745+
17311746
def merge_override_transcript(
17321747
edited_transcript: list[dict[str, Any]],
17331748
mirrored_transcript: list[dict[str, Any]],
@@ -1748,7 +1763,7 @@ def merge_override_transcript(
17481763
if base and base_prefix >= len(base):
17491764
return append_non_duplicate(base, source[base_prefix:])
17501765

1751-
latest_tail = latest_user_turn_tail(source)
1766+
latest_tail = latest_user_turn_tail(source) or latest_conversation_turn_tail(source)
17521767
if latest_tail:
17531768
return append_non_duplicate(base, latest_tail)
17541769
return base
@@ -1785,6 +1800,10 @@ def do_OPTIONS(self) -> None:
17851800

17861801
def do_GET(self) -> None:
17871802
parsed = urllib.parse.urlparse(self.path)
1803+
if parsed.path == "/api/proxy/health":
1804+
self._send_json({"ok": True})
1805+
return
1806+
17881807
proxy_log(
17891808
"incoming get "
17901809
f"path={parsed.path} "

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
},

0 commit comments

Comments
 (0)