From b837af33a5caf859b065bd1ab19078125f7e44e7 Mon Sep 17 00:00:00 2001 From: Vitaly Ostanin Date: Thu, 2 Apr 2026 10:00:35 +0300 Subject: [PATCH 1/5] feat: add session-color patch for env-based prompt bar color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set the session prompt bar color via TWEAKCC_SESSION_COLOR env var. Intended for use in wrapper scripts (e.g. ccw) that determine color based on the working directory before launching Claude Code. Valid values: red, blue, green, yellow, purple, orange, pink, cyan. Invalid or unset values are silently ignored (no color applied). How it works: Claude Code has two app state initialization sites — the CLI bootstrap (which builds initialState inline with dynamic values like effortValue, fastMode) and the default state factory u3H() used as fallback. Neither includes standaloneAgentContext, so it defaults to undefined and the prompt bar shows no color. The patch injects a standaloneAgentContext IIFE into both sites that reads TWEAKCC_SESSION_COLOR at startup, validates it against the hardcoded allowed list, and returns {name:"",color:value} or void 0. This is the same state shape that /color sets interactively. On session resume, Claude Code restores the color from the session file, so the env var only affects new sessions. --- src/patches/index.ts | 11 ++++++ src/patches/sessionColor.test.ts | 55 ++++++++++++++++++++++++++++ src/patches/sessionColor.ts | 63 ++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 src/patches/sessionColor.test.ts create mode 100644 src/patches/sessionColor.ts diff --git a/src/patches/index.ts b/src/patches/index.ts index a1c69861..5b2a97de 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -73,6 +73,7 @@ import { writeWorktreeMode } from './worktreeMode'; import { writeAllowCustomAgentModels } from './allowCustomAgentModels'; import { writeVoiceMode } from './voiceMode'; import { writeChannelsMode } from './channelsMode'; +import { writeSessionColor } from './sessionColor'; import { restoreNativeBinaryFromBackup, restoreClijsFromBackup, @@ -174,6 +175,13 @@ const PATCH_DEFINITIONS = [ group: PatchGroup.ALWAYS_APPLIED, description: `Statusline updates will be properly throttled instead of queued (or debounced)`, }, + { + id: 'session-color', + name: 'Session color from env', + group: PatchGroup.ALWAYS_APPLIED, + description: + 'Set session prompt bar color via TWEAKCC_SESSION_COLOR env var', + }, // Misc Configurable { id: 'model-customizations', @@ -888,6 +896,9 @@ export const applyCustomization = async ( fn: c => writeChannelsMode(c), condition: !!config.settings.misc?.enableChannelsMode, }, + 'session-color': { + fn: c => writeSessionColor(c), + }, }; // ========================================================================== diff --git a/src/patches/sessionColor.test.ts b/src/patches/sessionColor.test.ts new file mode 100644 index 00000000..d322e97d --- /dev/null +++ b/src/patches/sessionColor.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { writeSessionColor } from './sessionColor'; + +const makeCLIState = () => + 'effortValue:oR(w.effort),' + + 'activeOverlays:new Set,fastMode:cP8(N5),' + + '...(uF()&&d1&&{advisorModel:d1})'; + +const makeDefaultState = () => + 'effortValue:void 0,' + 'activeOverlays:new Set,fastMode:!1}'; + +const makeBoth = () => + 'first{' + makeDefaultState() + 'second{' + makeCLIState(); + +describe('sessionColor', () => { + describe('writeSessionColor', () => { + it('should inject into CLI initialState', () => { + const result = writeSessionColor(makeCLIState()); + expect(result).not.toBeNull(); + expect(result).toContain('TWEAKCC_SESSION_COLOR'); + expect(result).toContain('{name:"",color:__c}'); + }); + + it('should inject into default app state', () => { + const result = writeSessionColor(makeDefaultState()); + expect(result).not.toBeNull(); + expect(result).toContain('TWEAKCC_SESSION_COLOR'); + }); + + it('should patch both locations when both present', () => { + const result = writeSessionColor(makeBoth())!; + expect(result).not.toBeNull(); + const count = (result.match(/TWEAKCC_SESSION_COLOR/g) || []).length; + expect(count).toBe(2); + }); + + it('should validate color against allowed list', () => { + const result = writeSessionColor(makeCLIState())!; + expect(result).toContain('.includes(__c)'); + expect(result).toContain('"green"'); + expect(result).toContain('"cyan"'); + }); + + it('should be idempotent', () => { + const first = writeSessionColor(makeBoth())!; + const second = writeSessionColor(first)!; + expect(second).toBe(first); + }); + + it('should return null when no pattern found', () => { + const result = writeSessionColor('not a valid file'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/patches/sessionColor.ts b/src/patches/sessionColor.ts new file mode 100644 index 00000000..5ac5105a --- /dev/null +++ b/src/patches/sessionColor.ts @@ -0,0 +1,63 @@ +import { showDiff } from './index'; +import { debug } from '../utils'; + +const VALID_COLORS = [ + 'red', + 'blue', + 'green', + 'yellow', + 'purple', + 'orange', + 'pink', + 'cyan', +]; + +const INJECTION = + `,standaloneAgentContext:` + + `(()=>{` + + `let __c=process.env.TWEAKCC_SESSION_COLOR;` + + `return __c&&${JSON.stringify(VALID_COLORS)}.includes(__c)` + + `?{name:"",color:__c}` + + `:void 0` + + `})()`; + +export const writeSessionColor = (oldFile: string): string | null => { + if (oldFile.includes('TWEAKCC_SESSION_COLOR')) { + return oldFile; + } + + const patterns = [ + /,activeOverlays:new Set,fastMode:[$\w]+\([$\w]+\)/, + /,activeOverlays:new Set,fastMode:!1\}/, + ]; + + let result = oldFile; + let patched = false; + + for (const pattern of patterns) { + const match = result.match(pattern); + if (!match || match.index === undefined) continue; + + const replacement = INJECTION + match[0]; + result = + result.slice(0, match.index) + + replacement + + result.slice(match.index + match[0].length); + + showDiff( + oldFile, + result, + replacement, + match.index, + match.index + match[0].length + ); + patched = true; + } + + if (!patched) { + debug('patch: sessionColor: failed to find app state init patterns'); + return null; + } + + return result; +}; From ecfbe7523ef0104adc9910b34dcf6fef83a0c01d Mon Sep 17 00:00:00 2001 From: Vitaly Ostanin Date: Thu, 2 Apr 2026 10:27:06 +0300 Subject: [PATCH 2/5] Address review feedback: ordering and idempotency guard 1. Move session-color definition into the ALWAYS_APPLIED block (next to statusline-update-throttle) for ordering consistency. 2. Tighten idempotency check: match the full injection sentinel 'standaloneAgentContext:(()=>{let __c=process.env.TWEAKCC_SESSION_COLOR;' instead of the broad 'TWEAKCC_SESSION_COLOR' substring to avoid false-positive early returns. 3. Rebase onto upstream/main to resolve conflict with channels-mode patch (#653). --- src/patches/sessionColor.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/patches/sessionColor.ts b/src/patches/sessionColor.ts index 5ac5105a..16282cdb 100644 --- a/src/patches/sessionColor.ts +++ b/src/patches/sessionColor.ts @@ -22,7 +22,11 @@ const INJECTION = `})()`; export const writeSessionColor = (oldFile: string): string | null => { - if (oldFile.includes('TWEAKCC_SESSION_COLOR')) { + if ( + oldFile.includes( + 'standaloneAgentContext:(()=>{let __c=process.env.TWEAKCC_SESSION_COLOR;' + ) + ) { return oldFile; } From 46f3e6a3e481d96685470a0985b8c48c08e43343 Mon Sep 17 00:00:00 2001 From: Vitaly Ostanin Date: Thu, 2 Apr 2026 16:56:23 +0300 Subject: [PATCH 3/5] Fix showDiff args: use pre-patch buffer for consistent indices --- src/patches/sessionColor.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/patches/sessionColor.ts b/src/patches/sessionColor.ts index 16282cdb..c0ef6350 100644 --- a/src/patches/sessionColor.ts +++ b/src/patches/sessionColor.ts @@ -42,14 +42,15 @@ export const writeSessionColor = (oldFile: string): string | null => { const match = result.match(pattern); if (!match || match.index === undefined) continue; + const prePatch = result; const replacement = INJECTION + match[0]; result = - result.slice(0, match.index) + + prePatch.slice(0, match.index) + replacement + - result.slice(match.index + match[0].length); + prePatch.slice(match.index + match[0].length); showDiff( - oldFile, + prePatch, result, replacement, match.index, From 644621ad5e20342b095eadc2d2a76547fd1ebb24 Mon Sep 17 00:00:00 2001 From: Vitaly Ostanin Date: Fri, 10 Apr 2026 04:53:40 +0300 Subject: [PATCH 4/5] Persist session color to transcript for resume support Patch saveAgentColor to expose it via globalThis, then call it from the INJECTION via queueMicrotask so the env-based color is written to the session transcript. On resume, computeStandaloneAgentContext reads agentColor from the log and restores the prompt bar color automatically. --- src/patches/sessionColor.test.ts | 57 +++++++++++++++++++++++++++----- src/patches/sessionColor.ts | 55 ++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/patches/sessionColor.test.ts b/src/patches/sessionColor.test.ts index d322e97d..41488a0e 100644 --- a/src/patches/sessionColor.test.ts +++ b/src/patches/sessionColor.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { writeSessionColor } from './sessionColor'; +import { writeSessionColor, patchSaveAgentColor } from './sessionColor'; + +const makeSaveAgentColor = () => + ';async function Mr$(H,$,q){let K=q??sT(H);' + + 'if(Hv(K,{type:"agent-color",agentColor:$,sessionId:H}),H===V$())' + + 'WA().currentSessionAgentColor=$;c("tengu_agent_color_set",{})}'; const makeCLIState = () => 'effortValue:oR(w.effort),' + @@ -12,37 +17,41 @@ const makeDefaultState = () => const makeBoth = () => 'first{' + makeDefaultState() + 'second{' + makeCLIState(); +const makeFullFile = () => makeBoth() + makeSaveAgentColor(); + describe('sessionColor', () => { describe('writeSessionColor', () => { - it('should inject into CLI initialState', () => { - const result = writeSessionColor(makeCLIState()); + it('should inject into CLI initialState and patch saveAgentColor', () => { + const result = writeSessionColor(makeFullFile()); expect(result).not.toBeNull(); expect(result).toContain('TWEAKCC_SESSION_COLOR'); expect(result).toContain('{name:"",color:__c}'); + expect(result).toContain('__tweakccSaveAgentColor'); }); it('should inject into default app state', () => { - const result = writeSessionColor(makeDefaultState()); + const input = makeDefaultState() + makeSaveAgentColor(); + const result = writeSessionColor(input); expect(result).not.toBeNull(); expect(result).toContain('TWEAKCC_SESSION_COLOR'); }); - it('should patch both locations when both present', () => { - const result = writeSessionColor(makeBoth())!; + it('should patch both initialState locations', () => { + const result = writeSessionColor(makeFullFile())!; expect(result).not.toBeNull(); const count = (result.match(/TWEAKCC_SESSION_COLOR/g) || []).length; expect(count).toBe(2); }); it('should validate color against allowed list', () => { - const result = writeSessionColor(makeCLIState())!; + const result = writeSessionColor(makeFullFile())!; expect(result).toContain('.includes(__c)'); expect(result).toContain('"green"'); expect(result).toContain('"cyan"'); }); it('should be idempotent', () => { - const first = writeSessionColor(makeBoth())!; + const first = writeSessionColor(makeFullFile())!; const second = writeSessionColor(first)!; expect(second).toBe(first); }); @@ -51,5 +60,37 @@ describe('sessionColor', () => { const result = writeSessionColor('not a valid file'); expect(result).toBeNull(); }); + + it('should schedule color save via queueMicrotask', () => { + const result = writeSessionColor(makeFullFile())!; + expect(result).toContain('queueMicrotask'); + expect(result).toContain('__tweakccSaveAgentColor'); + }); + + it('should expose saveAgentColor on globalThis', () => { + const result = writeSessionColor(makeFullFile())!; + expect(result).toContain( + 'globalThis.__tweakccSaveAgentColor=(c)=>Mr$(V$(),c)' + ); + }); + }); + + describe('patchSaveAgentColor', () => { + it('should find and patch saveAgentColor', () => { + const result = patchSaveAgentColor(makeSaveAgentColor()); + expect(result).not.toBeNull(); + expect(result).toContain('globalThis.__tweakccSaveAgentColor'); + }); + + it('should return null when pattern not found', () => { + const result = patchSaveAgentColor('no match here'); + expect(result).toBeNull(); + }); + + it('should preserve original function', () => { + const result = patchSaveAgentColor(makeSaveAgentColor())!; + expect(result).toContain('async function Mr$'); + expect(result).toContain('type:"agent-color"'); + }); }); }); diff --git a/src/patches/sessionColor.ts b/src/patches/sessionColor.ts index c0ef6350..1ea46ff7 100644 --- a/src/patches/sessionColor.ts +++ b/src/patches/sessionColor.ts @@ -16,9 +16,11 @@ const INJECTION = `,standaloneAgentContext:` + `(()=>{` + `let __c=process.env.TWEAKCC_SESSION_COLOR;` + - `return __c&&${JSON.stringify(VALID_COLORS)}.includes(__c)` + - `?{name:"",color:__c}` + - `:void 0` + + `if(!__c||!${JSON.stringify(VALID_COLORS)}.includes(__c))return void 0;` + + `queueMicrotask(()=>{` + + `if(globalThis.__tweakccSaveAgentColor)globalThis.__tweakccSaveAgentColor(__c)` + + `});` + + `return{name:"",color:__c}` + `})()`; export const writeSessionColor = (oldFile: string): string | null => { @@ -64,5 +66,52 @@ export const writeSessionColor = (oldFile: string): string | null => { return null; } + const saveColorResult = patchSaveAgentColor(result); + if (!saveColorResult) { + debug('patch: sessionColor: failed to patch saveAgentColor'); + return null; + } + + return saveColorResult; +}; + +export const patchSaveAgentColor = (oldFile: string): string | null => { + const pattern = new RegExp( + '([,;{}])' + + '(async function ([$\\w]+)' + + '\\(([$\\w]+),([$\\w]+),([$\\w]+)\\)' + + '\\{let [$\\w]+=\\6\\?\\?[$\\w]+\\(\\4\\);' + + 'if\\([$\\w]+\\([$\\w]+,' + + '\\{type:"agent-color",agentColor:\\5,sessionId:\\4\\}\\),' + + '\\4===([$\\w]+)\\(\\)\\))' + ); + const match = oldFile.match(pattern); + if (!match || match.index === undefined) { + return null; + } + + const delimiter = match[1]; + const funcBody = match[2]; + const funcName = match[3]; + const getSessionIdName = match[7]; + + const replacement = + `${delimiter}globalThis.__tweakccSaveAgentColor=` + + `(c)=>${funcName}(${getSessionIdName}(),c);` + + funcBody; + + const result = + oldFile.slice(0, match.index) + + replacement + + oldFile.slice(match.index + match[0].length); + + showDiff( + oldFile, + result, + replacement, + match.index, + match.index + match[0].length + ); + return result; }; From 9cbff055e9999e4bbbb724674504a159b448cd84 Mon Sep 17 00:00:00 2001 From: Vitaly Ostanin Date: Fri, 10 Apr 2026 05:13:52 +0300 Subject: [PATCH 5/5] Fix showDiff injectedText args and move implementation to Always Applied block Pass only the actual injected text (INJECTION / injection) to showDiff instead of the full replacement string, so debug diffs don't misleadingly show original matched text as newly inserted. Move session-color implementation entry next to other Always Applied patches. --- src/patches/index.ts | 6 +++--- src/patches/sessionColor.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/patches/index.ts b/src/patches/index.ts index 5b2a97de..2e088007 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -671,6 +671,9 @@ export const applyCustomization = async ( ), condition: config.settings.misc?.statuslineThrottleMs != null, }, + 'session-color': { + fn: c => writeSessionColor(c), + }, // Misc Configurable 'patches-applied-indication': { fn: c => @@ -896,9 +899,6 @@ export const applyCustomization = async ( fn: c => writeChannelsMode(c), condition: !!config.settings.misc?.enableChannelsMode, }, - 'session-color': { - fn: c => writeSessionColor(c), - }, }; // ========================================================================== diff --git a/src/patches/sessionColor.ts b/src/patches/sessionColor.ts index 1ea46ff7..89c8e783 100644 --- a/src/patches/sessionColor.ts +++ b/src/patches/sessionColor.ts @@ -54,7 +54,7 @@ export const writeSessionColor = (oldFile: string): string | null => { showDiff( prePatch, result, - replacement, + INJECTION, match.index, match.index + match[0].length ); @@ -95,10 +95,11 @@ export const patchSaveAgentColor = (oldFile: string): string | null => { const funcName = match[3]; const getSessionIdName = match[7]; - const replacement = - `${delimiter}globalThis.__tweakccSaveAgentColor=` + - `(c)=>${funcName}(${getSessionIdName}(),c);` + - funcBody; + const injection = + `globalThis.__tweakccSaveAgentColor=` + + `(c)=>${funcName}(${getSessionIdName}(),c);`; + + const replacement = `${delimiter}${injection}${funcBody}`; const result = oldFile.slice(0, match.index) + @@ -108,7 +109,7 @@ export const patchSaveAgentColor = (oldFile: string): string | null => { showDiff( oldFile, result, - replacement, + injection, match.index, match.index + match[0].length );