Skip to content

Commit 58816d5

Browse files
committed
fix: windows PATH registry; claude-code per-session settings; revert opencode injection
1 parent 3823b24 commit 58816d5

7 files changed

Lines changed: 127 additions & 83 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Tday — The Ultimate Harness Agent Terminal Launcher
1+
# Tday — All-in-One Harness Agent Terminal
22

33
> One terminal launcher for every coding-agent harness — Claude Code, Codex, Copilot CLI, OpenCode, Pi, and more. Browser-style tabs, unified provider config, auto-detected local inference, long-term memory, and cross-agent token analytics.
44

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tday/desktop",
3-
"version": "0.8.6",
3+
"version": "0.8.7",
44
"private": true,
55
"description": "Tday desktop app",
66
"author": "unbug",

apps/desktop/src/main/index.ts

Lines changed: 88 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain, shell, dialog } from 'electron';
22
import { electronApp } from '@electron-toolkit/utils';
33
import { spawn as spawnPty } from 'node-pty';
44
import { spawn as spawnChild } from 'node:child_process';
5-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
66
import { join } from 'node:path';
77
import { homedir } from 'node:os';
88

@@ -70,6 +70,22 @@ import { createWindow, watchWindowShortcuts, installAppMenu, mainWindow } from '
7070

7171
const localGatewayManager = createLocalGatewayManager();
7272

73+
// ── claude-code concurrent-session state ──────────────────────────────────────
74+
// claude-code reads ~/.claude/settings.json AFTER process.env, overriding any
75+
// env vars we inject. To allow multiple tabs to run claude-code simultaneously
76+
// with different providers, we use this strategy:
77+
//
78+
// 1. Each session writes a per-session temp settings file under ~/.claude/
79+
// and passes "--settings <tempFile>" so its provider env takes effect.
80+
// 2. The global ~/.claude/settings.json env section must not interfere, so
81+
// we clear it when the first session starts (ref count 0→1) and restore
82+
// the original when the last session exits (ref count 1→0).
83+
//
84+
// This means N concurrent claude-code tabs each get their own independent
85+
// provider config without fighting over the single global settings.json.
86+
const claudeSessionCount = { n: 0 };
87+
let claudeGlobalSettingsBackup: string | null = undefined as unknown as string | null; // undefined = never read
88+
7389
// ── CronJob scheduler ─────────────────────────────────────────────────────────
7490

7591
function fireCronJob(job: CronJob): void {
@@ -294,30 +310,42 @@ function registerIpc(): void {
294310
env = piLike.env;
295311

296312
// ── claude-code provider override ─────────────────────────────────────
297-
// claude-code re-injects env vars from ~/.claude/settings.json AFTER the
298-
// process env is set, so any ANTHROPIC_BASE_URL in settings.json (e.g.
299-
// pointing to a user's global DeepSeek config) silently overrides what
300-
// tday sets via process.env or --settings.
313+
// claude-code reads ~/.claude/settings.json env section AFTER process.env,
314+
// silently overriding whatever we inject. We handle this with two layers:
315+
//
316+
// Layer 1 — per-session temp settings file:
317+
// Write our provider env to a per-session temp file
318+
// (~/.claude/tday-session-<tabId>.json) and pass --settings <file>.
319+
// This file is deleted when the PTY exits.
301320
//
302-
// Strategy (following cc-switch): directly patch ~/.claude/settings.json
303-
// before spawning. We save the original content first and restore it when
304-
// the PTY exits, so the user's settings are always preserved.
321+
// Layer 2 — global env section cleared (ref-counted):
322+
// The global settings.json env section must not interfere with the
323+
// per-session temp file. On the first concurrent claude-code session
324+
// (ref count 0→1) we blank the env keys in the global file and save
325+
// the original. On the last session exit (ref count 1→0) we restore
326+
// the original. Multiple concurrent tabs are each isolated via their
327+
// own temp file while the global env stays silent.
305328
if (req.agentId === 'claude-code' && effectiveProvider) {
306329
const cp = effectiveProvider;
307-
// LM Studio and Ollama expose a native Anthropic-compatible endpoint at
308-
// their root URL (e.g. http://localhost:1234), NOT at the /v1 sub-path
309-
// that is used for their OpenAI-compat endpoint. The Anthropic SDK
310-
// appends /v1/messages automatically, so we must strip the trailing
311-
// /v1 that tday stores as the OpenAI base URL.
330+
// Resolve the correct Anthropic-compatible base URL per provider kind.
331+
// - LM Studio/Ollama: strip /v1 (native Anthropic endpoint at root)
332+
// - DeepSeek: strip /v1 then append /anthropic
333+
// - Others: use as-is (cloud providers already point to the right base)
312334
const LOCAL_OAI_COMPAT = new Set(['ollama', 'lmstudio', 'litellm', 'vllm', 'sglang']);
313335
const rawUrl = cp.baseUrl ?? '';
314-
const resolvedUrl = LOCAL_OAI_COMPAT.has(cp.kind ?? '')
315-
? rawUrl.replace(/\/v1\/?$/, '') // http://localhost:1234/v1 → http://localhost:1234
316-
: rawUrl;
336+
let resolvedUrl: string;
337+
if (LOCAL_OAI_COMPAT.has(cp.kind ?? '')) {
338+
resolvedUrl = rawUrl.replace(/\/v1\/?$/, '');
339+
} else if (cp.kind === 'deepseek') {
340+
const base = rawUrl.replace(/\/$/, '').replace(/\/v1\/?$/, '');
341+
resolvedUrl = base.endsWith('/anthropic') ? base : `${base}/anthropic`;
342+
} else {
343+
resolvedUrl = rawUrl;
344+
}
317345
const apiKey = cp.apiKey ?? 'no-key-required';
318346

319-
// Build the env section to inject. Model override keys are blanked so
320-
// claude-code won't route to a stale model name from user's settings.
347+
// Env overrides for this session. Model keys are cleared so a stale
348+
// model name in the user's global settings won't take effect.
321349
const envPatch: Record<string, string> = {
322350
ANTHROPIC_MODEL: '',
323351
ANTHROPIC_DEFAULT_OPUS_MODEL: '',
@@ -335,84 +363,66 @@ function registerIpc(): void {
335363
envPatch.ANTHROPIC_AUTH_TOKEN = apiKey;
336364
}
337365

338-
// Patch ~/.claude/settings.json and schedule a restore on PTY exit.
339366
const claudeDir = join(homedir(), '.claude');
340-
const claudeSettingsPath = join(claudeDir, 'settings.json');
341-
let originalSettings: string | null = null;
367+
const globalSettingsPath = join(claudeDir, 'settings.json');
368+
// Per-session temp settings file — unique per tab, never collides.
369+
const sessionSettingsPath = join(claudeDir, `tday-session-${req.tabId}.json`);
370+
342371
try {
343372
mkdirSync(claudeDir, { recursive: true });
344-
// Save original so we can restore it after claude-code exits.
345-
try { originalSettings = readFileSync(claudeSettingsPath, 'utf8'); } catch { /* file may not exist */ }
346-
const existing: Record<string, unknown> =
347-
originalSettings ? (JSON.parse(originalSettings) as Record<string, unknown>) : {};
348-
const patched = {
349-
...existing,
350-
env: { ...(existing.env as Record<string, string> | undefined ?? {}), ...envPatch },
351-
};
352-
writeFileSync(claudeSettingsPath, JSON.stringify(patched, null, 2), 'utf8');
373+
374+
// ── Layer 1: write per-session temp file ──────────────────────────
375+
const sessionSettings = { env: envPatch };
376+
writeFileSync(sessionSettingsPath, JSON.stringify(sessionSettings, null, 2), 'utf8');
377+
// Instruct claude-code to load this file (merged with global).
378+
args = ['--settings', sessionSettingsPath, ...args];
379+
380+
// ── Layer 2: blank global env (ref-counted) ───────────────────────
381+
if (claudeSessionCount.n === 0) {
382+
// First session: capture the true original and clear global env.
383+
try { claudeGlobalSettingsBackup = readFileSync(globalSettingsPath, 'utf8'); }
384+
catch { claudeGlobalSettingsBackup = null; }
385+
386+
const existing: Record<string, unknown> =
387+
claudeGlobalSettingsBackup
388+
? (JSON.parse(claudeGlobalSettingsBackup) as Record<string, unknown>)
389+
: {};
390+
const neutralized = { ...existing, env: {} };
391+
writeFileSync(globalSettingsPath, JSON.stringify(neutralized, null, 2), 'utf8');
392+
}
393+
claudeSessionCount.n += 1;
353394
} catch (e) {
354-
console.warn('[tday] could not patch ~/.claude/settings.json:', e);
395+
console.warn('[tday] could not set up claude-code session settings:', e);
355396
}
356-
// Store restore callback — used in pty.onExit below.
397+
398+
// Restore callback: clean up temp file and (last session) restore global.
357399
claudeSettingsRestore = () => {
358400
try {
359-
if (originalSettings === null) return; // file didn't exist — nothing to restore
360-
writeFileSync(claudeSettingsPath, originalSettings, 'utf8');
401+
try { unlinkSync(sessionSettingsPath); } catch { /* ok */ }
402+
claudeSessionCount.n = Math.max(0, claudeSessionCount.n - 1);
403+
if (claudeSessionCount.n === 0) {
404+
// Last session done — restore global settings.json.
405+
if (claudeGlobalSettingsBackup === null) {
406+
// File didn't exist before; remove what we created.
407+
try { unlinkSync(globalSettingsPath); } catch { /* ok */ }
408+
} else if (claudeGlobalSettingsBackup !== undefined) {
409+
writeFileSync(globalSettingsPath, claudeGlobalSettingsBackup, 'utf8');
410+
}
411+
claudeGlobalSettingsBackup = undefined as unknown as string | null;
412+
}
361413
} catch (e) {
362-
console.warn('[tday] could not restore ~/.claude/settings.json:', e);
414+
console.warn('[tday] could not restore claude-code settings:', e);
363415
}
364416
};
365417

366-
// 2. Also set the env vars in our process env for the child process
418+
// Also inject into process env for completeness.
367419
Object.assign(env, envPatch);
368420
}
369421

370422
if (req.agentId === 'deepseek-tui' && effectiveProvider) {
371423
Object.assign(env, deepseekTuiProviderEnv(effectiveProvider.kind, effectiveProvider.apiKey, effectiveProvider.baseUrl));
372424
}
373425

374-
// ── opencode provider override ─────────────────────────────────────────
375-
// opencode (local providers) needs the provider registered in its config.
376-
// Rather than mutating ~/.config/opencode/opencode.json, we use the
377-
// OPENCODE_CONFIG_CONTENT env var which opencode merges as a mid-priority
378-
// override (after global config, before project config). This tells
379-
// opencode about the custom provider + its baseURL + available models.
380-
if (req.agentId === 'opencode' && effectiveProvider) {
381-
const cp = effectiveProvider;
382-
const OC_LOCAL = new Set(['ollama', 'lmstudio', 'litellm', 'vllm', 'sglang']);
383-
if (cp.kind && OC_LOCAL.has(cp.kind) && cp.baseUrl) {
384-
// Collect all known model IDs, stripping any server-side prefix.
385-
const modelSet = new Set<string>();
386-
const strip = (m: string) => m.includes('/') ? m.replace(/^[^/]+\//, '') : m;
387-
if (cp.model) modelSet.add(strip(cp.model));
388-
for (const m of cp.discoveredModels ?? []) modelSet.add(strip(m));
389-
for (const m of cp.extraModels ?? []) modelSet.add(strip(m));
390-
if (modelSet.size === 0) modelSet.add('default');
391-
392-
const modelsMap: Record<string, { name: string }> = {};
393-
for (const m of modelSet) modelsMap[m] = { name: m };
394-
395-
const DISPLAY: Record<string, string> = {
396-
lmstudio: 'LM Studio (local)',
397-
ollama: 'Ollama (local)',
398-
litellm: 'LiteLLM (proxy)',
399-
vllm: 'vLLM (local)',
400-
sglang: 'SGLang (local)',
401-
};
402-
const providerDef: Record<string, unknown> = {
403-
npm: '@ai-sdk/openai-compatible',
404-
name: DISPLAY[cp.kind] ?? cp.kind,
405-
options: {
406-
baseURL: cp.baseUrl,
407-
...(cp.apiKey ? { apiKey: cp.apiKey } : { apiKey: 'no-key-required' }),
408-
},
409-
models: modelsMap,
410-
};
411-
const configContent = { provider: { [cp.kind]: providerDef } };
412-
env.OPENCODE_CONFIG_CONTENT = JSON.stringify(configContent);
413-
}
414-
}
415-
416426
if (gatewayResolution?.noProxyHosts?.length) appendNoProxy(env, gatewayResolution.noProxyHosts);
417427
launchCwd = normalizeLaunchCwd(piLike.cwd);
418428
}

apps/desktop/src/main/path-utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,40 @@ export function augmentPath(): void {
156156

157157
const current = process.env.PATH ?? '';
158158
const seen = new Set(current.split(PATH_SEP).filter(Boolean));
159+
160+
// Windows: read the user's and system's PATH directly from the registry.
161+
//
162+
// When Electron is launched from a desktop shortcut, it inherits Explorer's
163+
// environment which may be missing user-level PATH modifications (e.g. paths
164+
// added by Node.js, nvm-windows, Scoop, Volta, custom installers, etc.).
165+
// The definitive source for what the user sees in cmd.exe is the registry:
166+
// - HKLM\SYSTEM\...\Environment → system PATH
167+
// - HKCU\Environment → user PATH (merged on top by Explorer)
168+
// We read both and merge them in, expanding %VAR% references against the
169+
// current process.env so paths like %APPDATA%\npm resolve correctly.
170+
if (process.platform === 'win32') {
171+
const regKeys = [
172+
'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment',
173+
'HKCU\\Environment',
174+
];
175+
for (const key of regKeys) {
176+
try {
177+
const out = execFileSync('reg', ['query', key, '/v', 'Path'], {
178+
encoding: 'utf8',
179+
timeout: 3_000,
180+
});
181+
// Output line: " Path REG_EXPAND_SZ <value>"
182+
const match = out.match(/\s+Path\s+REG_(?:SZ|EXPAND_SZ)\s+(.+)/i);
183+
if (match?.[1]) {
184+
const expanded = match[1].trim().replace(/%([^%]+)%/g, (_, n: string) =>
185+
process.env[n] ?? process.env[n.toUpperCase()] ?? `%${n}%`,
186+
);
187+
for (const p of expanded.split(';').filter(Boolean)) seen.add(p);
188+
}
189+
} catch { /* ignore — reg may not exist or may be restricted */ }
190+
}
191+
}
192+
159193
for (const e of extras) if (existsSync(e)) seen.add(e);
160194
process.env.PATH = Array.from(seen).join(PATH_SEP);
161195
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tday",
3-
"version": "0.8.6",
3+
"version": "0.8.7",
44
"private": true,
55
"description": "The Ultimate Harness Agent Launcher",
66
"author": "unbug",

packages/adapters/pi/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tday/adapter-pi",
3-
"version": "0.8.6",
3+
"version": "0.8.7",
44
"private": true,
55
"type": "module",
66
"main": "./src/index.ts",

packages/shared/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tday/shared",
3-
"version": "0.8.6",
3+
"version": "0.8.7",
44
"private": true,
55
"type": "module",
66
"main": "./src/index.ts",

0 commit comments

Comments
 (0)